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

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

No files matched your search

+1 -1
View File
@@ -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.
---
+4 -11
View File
@@ -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(),
],
),
);
+7 -6
View File
@@ -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,
),
),
+28 -17
View File
@@ -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(
+2 -1
View File
@@ -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,
+4 -6
View File
@@ -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
),
),
],
+2 -1
View File
@@ -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,
+5 -4
View File
@@ -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,
),
),
+2 -1
View File
@@ -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,
+9 -8
View File
@@ -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,
-86
View File
@@ -1,86 +0,0 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/session_timer.dart';
import 'package:revoltvpn/logic/ad_manager.dart';
/// WatchAdButton is a voluntary option allowing the user to watch a rewarded video ad
/// and extend their active VPN tunnel session by 30 minutes as a reward.
class WatchAdButton extends StatelessWidget {
const WatchAdButton({super.key});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () async {
final adWatched = await context.read<AdManager>().showAd('bonus_ad');
if (!adWatched || !context.mounted) return;
// The actual session extension happens server-side via AdMob SSV.
// addBonusTime() syncs the new limits from the server.
final synced = await context.read<SessionTimer>().addBonusTime();
if (!context.mounted) return;
if (synced) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text(
'+30 minutes added!',
style: TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: const Color(0xFF006064),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
duration: const Duration(seconds: 2),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Could not confirm bonus. Please check your connection.',
style: TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: Color(0xFF5D4037),
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 3),
),
);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: const LinearGradient(
colors: [Color(0x1A00E5FF), Color(0x0D00E5FF)],
),
border: Border.all(
color: const Color.fromRGBO(0, 229, 255, 0.25),
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('❤️', style: TextStyle(fontSize: 14)),
SizedBox(width: 6),
Text(
'Support +30m',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF00E5FF),
letterSpacing: 0.5,
),
),
],
),
),
),
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
// =============================================================================
// app_colors.dart — Single source of truth for every color in ReVoltVPN
// ─────────────────────────────────────────────────────────────────────────────
// Import this file and reference AppColors.* instead of hardcoding hex values
// or Color.fromRGBO() calls anywhere else in the app.
// =============================================================================
abstract final class AppColors {
AppColors._();
// ── Primary: Cyan ──────────────────────────────────────────────────────────
static const Color cyan = Color(0xFF00E5FF);
static const Color cyan15 = Color(0x2600E5FF); // 15 % opacity
static const Color cyan25 = Color(0x4000E5FF); // 25 % opacity
static const Color cyan50 = Color(0x8000E5FF); // 50 % opacity
static const Color cyan60 = Color(0x9900E5FF); // 60 % opacity
static const Color cyan70 = Color(0xB300E5FF); // 70 % opacity
static const Color cyan05 = Color(0x0D00E5FF); // 5 % opacity
static const Color cyanGlow = Color(0x8800E5FF); // glowing border
// ── Backgrounds ────────────────────────────────────────────────────────────
static const Color bgDeep = Color(0xFF0D1117); // intro / scaffold
static const Color bgDark = Color(0xFF1A202C); // button circle
static const Color bgSurface = Color(0xFF151C28); // colorScheme surface
static const Color bgCard = Color(0xFF1E2533); // bottom bar, snackbar
static const Color bgOverlay = Color(0xAA0D1117); // semi-transparent overlay
// ── Slate / Gray ───────────────────────────────────────────────────────────
static const Color slate = Color(0xFF4A5568);
static const Color slate15 = Color(0x264A5568); // 15 % opacity
static const Color slate50 = Color(0x804A5568); // 50 % opacity
static const Color slate70 = Color(0xB34A5568); // 70 % opacity
static const Color slateAA = Color(0xAA4A5568); // ~67 % (legacy usage)
// ── Text ───────────────────────────────────────────────────────────────────
static const Color textWhite = Colors.white;
static const Color textDim = Colors.white54;
static const Color textWhite80 = Color(0xCCFFFFFF); // 80 % white
static const Color textMuted = Color(0xFFA0AEC0); // dialog body text
// ── Semantic ───────────────────────────────────────────────────────────────
static const Color green = Color(0xFF00E676);
static const Color green50 = Color(0x8000E676); // 50 % opacity
static const Color red = Color(0xFFEF5350);
// ── Glass / Borders ────────────────────────────────────────────────────────
static const Color glassBg = Color(0x22FFFFFF); // subtle glass background
static const Color glassBorder = Color(0x33FFFFFF); // muted translucent border
// ── Intro screen snackbars ─────────────────────────────────────────────────
static const Color snackSuccess = Color(0xFF006064);
static const Color snackError = Color(0xFF5D4037);
// ═══════════════════════════════════════════════════════════════════════════
// FUTURE — Yellow palette (not yet wired into the UI)
// ═══════════════════════════════════════════════════════════════════════════
static const Color yellow = Color(0xFFFFD600);
static const Color yellowDark = Color(0xFFFFAB00);
static const Color yellowLight = Color(0xFFFFFF00);
static const Color yellowFaded = Color(0x1AFFD600);
static const Color yellowAmber = Color(0xFFFFC107);
static const Color yellowMustard = Color(0xFFC6A700);
// ═══════════════════════════════════════════════════════════════════════════
// FUTURE — Black palette (not yet wired into the UI)
// ═══════════════════════════════════════════════════════════════════════════
static const Color black = Color(0xFF000000);
static const Color blackOff = Color(0xFF0A0A0A);
static const Color blackFaded = Color(0xAA000000);
static const Color blackWarm = Color(0xFF121212);
static const Color blackCharcoal = Color(0xFF1A1A1A);
}
-1
View File
@@ -17,7 +17,6 @@ abstract final class AppConfig {
// ── VLESS ──────────────────────────────────────────────────────────────
static const int serverPort = 443;
static const String vlessFlow = 'xtls-rprx-vision';
static const String vlessSecurity = 'tls';
static const String vlessType = 'ws';
-2
View File
@@ -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
-24
View File
@@ -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.
+12
View File
@@ -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
View File
@@ -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,
),
+5 -4
View File
@@ -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,
),
),
+4 -6
View File
@@ -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
View File
@@ -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():