Two-box throttle system + support top-up
- Server: two Xray inbounds (vless-normal / vless-throttled), 4GB threshold, management loop moves users between boxes instead of hard-killing. /session/status returns dynamic vless_path + is_throttled. Support ad callback (ad_type: support) extends session +30min +2GB. No hard quota kill — only 60-min expiry removes sessions. - App: session_timer detects vless_path change in 5s poll, triggers silent reconnect (skipCleanup, skipAdBypass, quickReconnect). New support_button.dart for top-up rewarded ads. hivemind_service uses server vless_path instead of hardcoded constant. vpn_connection: skipAdBypass, quickReconnect, skipCleanup params. - Config: Xray two-inbound template, nginx /tunnel-throttled with limit_rate 187k (1.5 Mbps). Updated AGENTS.md + SERVER_SETUP.md.
This commit is contained in:
1 parent
6825a264a5
commit
efcfe6e706
6 files changed
+282
-63
No files matched your search
@@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:revoltvpn/logic/ad_manager.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/app_colors.dart';
|
||||
|
||||
class SupportButton extends StatefulWidget {
|
||||
const SupportButton({super.key});
|
||||
|
||||
@override
|
||||
State<SupportButton> createState() => _SupportButtonState();
|
||||
}
|
||||
|
||||
class _SupportButtonState extends State<SupportButton> {
|
||||
bool _busy = false;
|
||||
|
||||
Future<void> _handleTap() async {
|
||||
if (_busy) return;
|
||||
|
||||
final ad = context.read<AdManager>();
|
||||
final timer = context.read<SessionTimer>();
|
||||
|
||||
// Only available when there's an active session.
|
||||
if (!timer.isRunning && !timer.hasSyncedOnce) return;
|
||||
|
||||
setState(() => _busy = true);
|
||||
|
||||
try {
|
||||
await ad.showAd('support');
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<SessionTimer>(
|
||||
builder: (context, timer, _) {
|
||||
if (!timer.isRunning && !timer.hasSyncedOnce) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _handleTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassBg,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: AppColors.accent.withAlpha(100),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.favorite_border, color: AppColors.accent, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
_busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'Support us \u2014 30 min!',
|
||||
style: TextStyle(
|
||||
color: AppColors.textWhite,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ class HivemindService {
|
||||
/// has usually already fired.
|
||||
static Future<String> fetchConfigWithPolling({
|
||||
void Function(int attempt, int total)? onAttempt,
|
||||
bool skipAdBypass = false,
|
||||
bool quickReconnect = false,
|
||||
}) async {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
|
||||
@@ -35,7 +37,7 @@ class HivemindService {
|
||||
final url = Uri.parse('${AppConfig.hivemindApiBase}/session/status?device_id=$deviceId');
|
||||
|
||||
// ── AD BYPASS (debug only) ────────────────────────────────────────
|
||||
if (kDebugMode) {
|
||||
if (kDebugMode && !skipAdBypass) {
|
||||
try {
|
||||
final customData = jsonEncode({'device_id': deviceId, 'nonce': nonce});
|
||||
final fakeUrl = Uri.parse(
|
||||
@@ -44,7 +46,7 @@ class HivemindService {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const maxAttempts = 5;
|
||||
final maxAttempts = quickReconnect ? 1 : 5;
|
||||
for (int i = 1; i <= maxAttempts; i++) {
|
||||
if (_currentCallId != callId) throw Exception('Cancelled');
|
||||
|
||||
@@ -59,8 +61,9 @@ class HivemindService {
|
||||
debugPrint('[HivemindService] Nonce mismatch — retrying…');
|
||||
} else if (data['active'] == true && data['vless_uuid'] != null) {
|
||||
final vlessUuid = data['vless_uuid'];
|
||||
final vlessPath = data['vless_path'] ?? AppConfig.vlessPath;
|
||||
final vlessUrl = 'vless://$vlessUuid@${AppConfig.serverDomain}:${AppConfig.serverPort}'
|
||||
'?path=${Uri.encodeComponent(AppConfig.vlessPath)}'
|
||||
'?path=${Uri.encodeComponent(vlessPath)}'
|
||||
'&security=${AppConfig.vlessSecurity}'
|
||||
'&type=${AppConfig.vlessType}'
|
||||
'#ReVoltVPN';
|
||||
|
||||
@@ -22,6 +22,10 @@ class SessionTimer extends ChangeNotifier {
|
||||
bool _isDisconnecting = false;
|
||||
bool _userInitiatedStop = false;
|
||||
|
||||
String? _currentPath;
|
||||
bool _isThrottled = false;
|
||||
bool _pathSwitchInProgress = false;
|
||||
|
||||
static const int _maxConsecutiveFailures = 3;
|
||||
|
||||
static const int _maxOfflineSeconds = 120;
|
||||
@@ -236,6 +240,29 @@ class SessionTimer extends ChangeNotifier {
|
||||
_lastUsedBytes = _usedBytes;
|
||||
|
||||
|
||||
// ── Detect path changes (throttle engage / disengage) ──────
|
||||
final serverPath = data['vless_path'] as String?;
|
||||
if (serverPath != null &&
|
||||
_currentPath != null &&
|
||||
serverPath != _currentPath &&
|
||||
!_pathSwitchInProgress) {
|
||||
debugPrint('[Timer] Path changed $_currentPath → $serverPath — reconnecting…');
|
||||
_currentPath = serverPath;
|
||||
_isThrottled = data['is_throttled'] ?? false;
|
||||
_pathSwitchInProgress = true;
|
||||
|
||||
_timer?.cancel();
|
||||
_isDisconnecting = true; // block _stopInternal during relocation
|
||||
await vpnConnection.disconnect(skipCleanup: true);
|
||||
await vpnConnection.connect(skipAdBypass: true, quickReconnect: true);
|
||||
|
||||
_resumeTicking();
|
||||
_pathSwitchInProgress = false;
|
||||
return;
|
||||
}
|
||||
_currentPath = serverPath;
|
||||
_isThrottled = data['is_throttled'] ?? false;
|
||||
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_hasSyncedOnce = true;
|
||||
|
||||
@@ -94,7 +94,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> connect() async {
|
||||
Future<bool> connect({bool skipAdBypass = false, bool quickReconnect = false}) async {
|
||||
if (_status == VpnStatus.connected || _status == VpnStatus.connecting) {
|
||||
return false;
|
||||
}
|
||||
@@ -118,8 +118,12 @@ class VpnConnection extends ChangeNotifier {
|
||||
String vlessUrl;
|
||||
try {
|
||||
vlessUrl = await HivemindService.fetchConfigWithPolling(
|
||||
skipAdBypass: skipAdBypass,
|
||||
quickReconnect: quickReconnect,
|
||||
onAttempt: (attempt, total) {
|
||||
_setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||
if (!quickReconnect) {
|
||||
_setStatus(VpnStatus.connecting, 'Contacting server ($attempt/$total)…');
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -156,31 +160,35 @@ class VpnConnection extends ChangeNotifier {
|
||||
config: config,
|
||||
);
|
||||
|
||||
// Give the tunnel a moment to stabilise
|
||||
_setStatus(VpnStatus.connecting, 'Verifying session…');
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (quickReconnect) {
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
} else {
|
||||
// Give the tunnel a moment to stabilise
|
||||
_setStatus(VpnStatus.connecting, 'Verifying session…');
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
|
||||
bool sessionOk = false;
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
sessionOk = await HivemindService.verifySession();
|
||||
if (sessionOk) break;
|
||||
if (attempt == 0) {
|
||||
debugPrint('[VPN] verifySession attempt 1 failed, retrying…');
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
bool sessionOk = false;
|
||||
for (int attempt = 0; attempt < 2; attempt++) {
|
||||
sessionOk = await HivemindService.verifySession();
|
||||
if (sessionOk) break;
|
||||
if (attempt == 0) {
|
||||
debugPrint('[VPN] verifySession attempt 1 failed, retrying…');
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionOk) {
|
||||
debugPrint('[VPN] Tunnel started but no server session — tearing down.');
|
||||
try {
|
||||
await _vless.stopVless();
|
||||
} catch (_) {}
|
||||
_errorMessage = 'Session not confirmed by server.\nPlease try again.';
|
||||
_setStatus(VpnStatus.error, 'Session rejected');
|
||||
return false;
|
||||
}
|
||||
if (!sessionOk) {
|
||||
debugPrint('[VPN] Tunnel started but no server session — tearing down.');
|
||||
try {
|
||||
await _vless.stopVless();
|
||||
} catch (_) {}
|
||||
_errorMessage = 'Session not confirmed by server.\nPlease try again.';
|
||||
_setStatus(VpnStatus.error, 'Session rejected');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
_setStatus(VpnStatus.connected, 'Secured');
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] VLESS tunnel error: $e');
|
||||
@@ -190,7 +198,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
Future<void> disconnect({bool skipCleanup = false}) async {
|
||||
_cancelled = true;
|
||||
HivemindService.cancel(); // abort any in-progress polling
|
||||
if (_status == VpnStatus.disconnected || _status == VpnStatus.disconnecting) {
|
||||
@@ -214,7 +222,9 @@ class VpnConnection extends ChangeNotifier {
|
||||
debugPrint('[VPN] VLESS stop error: $e');
|
||||
}
|
||||
|
||||
HivemindService.disconnectAndCleanup();
|
||||
if (!skipCleanup) {
|
||||
HivemindService.disconnectAndCleanup();
|
||||
}
|
||||
_setStatus(VpnStatus.disconnected, 'Tap to connect');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:revoltvpn/components/sidebar_drawer.dart';
|
||||
import 'package:revoltvpn/components/timer_box.dart';
|
||||
import 'package:revoltvpn/components/speed_box.dart';
|
||||
import 'package:revoltvpn/components/status_bar.dart';
|
||||
import 'package:revoltvpn/components/support_button.dart';
|
||||
|
||||
class MainScreen extends StatelessWidget {
|
||||
const MainScreen({super.key});
|
||||
@@ -47,6 +48,13 @@ class MainScreen extends StatelessWidget {
|
||||
child: const Center(child: TimerBox()),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 175, // <-- CHANGE ME: support button from top
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: const Center(child: SupportButton()),
|
||||
),
|
||||
|
||||
Positioned(
|
||||
top: 250, // <-- CHANGE ME: connect button from top
|
||||
left: 0,
|
||||
|
||||
Reference in new issue
Block a user