v2.0.0: VLESS cleanup — remove bonus ads, throttling, fix Xray compliance, rebrand colors
This commit is contained in:
1 parent
10a1450292
commit
d106356ca6
20 files changed
+304
-294
No files matched your search
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<VpnConnection, SessionTimer>(
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<ConnectButton>
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _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<ConnectButton>
|
||||
} 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<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();
|
||||
if (success) {
|
||||
timer.start('main_ad');
|
||||
@@ -104,9 +115,9 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
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<ConnectButton>
|
||||
'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<ConnectButton>
|
||||
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<ConnectButton>
|
||||
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<ConnectButton>
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool> 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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+7
-9
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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<IntroScreen> 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<IntroScreen> with WidgetsBindingObserver {
|
||||
return const Icon(
|
||||
Icons.shield,
|
||||
size: 140,
|
||||
color: Color(0xFF00E5FF),
|
||||
color: AppColors.cyan,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -79,7 +80,7 @@ class _IntroScreenState extends State<IntroScreen> 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<IntroScreen> with WidgetsBindingObserver {
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF00E5FF),
|
||||
color: AppColors.cyan,
|
||||
strokeWidth: 3.0,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+138
-106
@@ -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
|
||||
|
||||
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)
|
||||
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,
|
||||
@@ -190,6 +275,11 @@ def session_stop():
|
||||
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,23 +302,9 @@ 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
|
||||
|
||||
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
|
||||
|
||||
else:
|
||||
# Create a brand new session (Main Ad)
|
||||
existing = active_sessions.get(device_id)
|
||||
vless_uuid = str(uuid.uuid4())
|
||||
|
||||
@@ -236,7 +312,6 @@ def _start_or_extend_session(device_id, ad_type, nonce=None):
|
||||
"vless_uuid": vless_uuid,
|
||||
"expires_at": now + (MAIN_AD_MINUTES * 60),
|
||||
"quota_bytes": MAIN_AD_BYTES,
|
||||
"is_throttled": False,
|
||||
"nonce": nonce,
|
||||
}
|
||||
|
||||
@@ -245,25 +320,23 @@ def _start_or_extend_session(device_id, ad_type, nonce=None):
|
||||
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():
|
||||
|
||||
Reference in new issue
Block a user