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
+317
-307
No files matched your search
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ──────────────────────────────────────────────────────────────
|
// ── 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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+151
-119
@@ -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,
|
||||||
@@ -190,6 +275,11 @@ def session_stop():
|
|||||||
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():
|
||||||
|
|||||||
Reference in new issue
Block a user