diff --git a/lib/components/support_button.dart b/lib/components/support_button.dart new file mode 100644 index 0000000..85f5af3 --- /dev/null +++ b/lib/components/support_button.dart @@ -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 createState() => _SupportButtonState(); +} + +class _SupportButtonState extends State { + bool _busy = false; + + Future _handleTap() async { + if (_busy) return; + + final ad = context.read(); + final timer = context.read(); + + // 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( + 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, + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index a22472d..d2a3448 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -24,6 +24,8 @@ class HivemindService { /// has usually already fired. static Future 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'; diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index fd577b5..a30652d 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -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; diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 87dbda7..7636c42 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -94,7 +94,7 @@ class VpnConnection extends ChangeNotifier { } } - Future connect() async { + Future 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 disconnect() async { + Future 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'); } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index e8947b8..2733f05 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -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, diff --git a/server_hivemind_5_0.py b/server_hivemind_5_0.py index 302ce06..6556c58 100644 --- a/server_hivemind_5_0.py +++ b/server_hivemind_5_0.py @@ -79,37 +79,40 @@ CHECK_INTERVAL_SECONDS = 60 # Checking every 1 minute is extremely safe and MAIN_AD_MINUTES = 60 MAIN_AD_BYTES = 10 * 1024 * 1024 * 1024 # 10 GB (generous — no mobile device hits this in 1 hr) +# Support Ad Rewards (top-up) +SUPPORT_AD_MINUTES = 30 +SUPPORT_AD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB + # 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 +# • Two VLESS inbounds: vless-normal (full speed) + vless-throttled (1.5 Mbps via nginx) +# • nginx routes /tunnel → :8443 and /tunnel-throttled → :8444 with limit_rate 187k +# • Hivemind moves users between inbounds at 4 GB threshold # # { # "log": { "loglevel": "warning" }, # "inbounds": [ # { -# "tag": "vless-in", +# "tag": "vless-normal", # "port": 8443, # "listen": "127.0.0.1", # "protocol": "vless", -# "settings": { -# "clients": [], -# "decryption": "none" -# }, -# "streamSettings": { -# "method": "ws", -# "security": "none", -# "wsSettings": { "path": "/tunnel" } -# }, +# "settings": { "clients": [], "decryption": "none" }, +# "streamSettings": { "method": "ws", "wsSettings": { "path": "/tunnel" } }, +# "sniffing": { "enabled": true, "destOverride": ["http", "tls"] } +# }, +# { +# "tag": "vless-throttled", +# "port": 8444, +# "listen": "127.0.0.1", +# "protocol": "vless", +# "settings": { "clients": [], "decryption": "none" }, +# "streamSettings": { "method": "ws", "wsSettings": { "path": "/tunnel" } }, # "sniffing": { "enabled": true, "destOverride": ["http", "tls"] } # } # ], @@ -136,11 +139,38 @@ VLESS_PATH = "/tunnel" # } # } # } +# "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_INBOUND_TAG = "vless-in" # legacy — kept for backwards compat +VLESS_NORMAL_TAG = "vless-normal" # full-speed inbox +VLESS_THROTTLED_TAG = "vless-throttled" # 1.5 Mbps inbox + +THROTTLE_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB — throttle beyond this # AdMob SSV verification toggle # True = dev mode, accept any callback (no signature check) @@ -223,8 +253,13 @@ def admob_callback(): print(f"[ADMOB] Rejected — invalid device_id: {str(device_id)[:32]}...") return "Invalid device_id", 400 - nonce = custom_data.get('nonce', None) - success, msg = _start_or_extend_session(device_id, nonce) + nonce = custom_data.get('nonce', None) + ad_type = custom_data.get('ad_type', 'main') + + if ad_type == 'support': + success, msg = _extend_session(device_id, nonce) + else: + success, msg = _start_or_extend_session(device_id, nonce) return ("OK", 200) if success else (msg, 500) except Exception as e: @@ -266,6 +301,8 @@ def session_status(): "used_bytes": used_bytes, "remaining_bytes": remaining_data, "nonce": session.get("nonce", ""), + "vless_path": "/tunnel-throttled" if session.get("throttled") else VLESS_PATH, + "is_throttled": session.get("throttled", False), }) @@ -313,6 +350,7 @@ def _start_or_extend_session(device_id, nonce=None): "expires_at": now + (MAIN_AD_MINUTES * 60), "quota_bytes": MAIN_AD_BYTES, "nonce": nonce, + "throttled": False, } if existing: @@ -326,10 +364,10 @@ def _start_or_extend_session(device_id, nonce=None): op, arg = xray_action[0], xray_action[1] if op == "replace": remove_vless_client(device_id) - add_vless_client(arg, device_id) + add_vless_client(arg, device_id, inbound_tag=VLESS_NORMAL_TAG) return True, "Session active." elif op == "add": - add_vless_client(arg, device_id) + add_vless_client(arg, device_id, inbound_tag=VLESS_NORMAL_TAG) return True, "Session active." else: raise RuntimeError( @@ -339,6 +377,29 @@ def _start_or_extend_session(device_id, nonce=None): ) +def _extend_session(device_id, nonce=None): + """Top-up an EXISTING session — adds time + quota without replacing the UUID. + + Used by the 'Support us' rewarded ad. Does NOT touch the throttle state + or move the user between inbounds. + """ + with sessions_lock: + session = active_sessions.get(device_id) + if not session: + print(f"[SESSION] Extend failed — no active session for {device_id}.") + return False, "No active session to extend" + + session["expires_at"] += SUPPORT_AD_MINUTES * 60 + session["quota_bytes"] += SUPPORT_AD_BYTES + session["nonce"] = nonce + + print(f"[SESSION] Extended for {device_id}: " + f"+{SUPPORT_AD_MINUTES} min, " + f"+{SUPPORT_AD_BYTES/1e9:.1f} GB, " + f"new quota={session['quota_bytes']/1e9:.1f} GB") + + return True, "Session extended" + # ============================================================================== # ADMOB SERVER-TO-SERVER (SSV) VERIFICATION @@ -438,7 +499,9 @@ def management_loop(): # ── Phase 1: decide what to do under the lock ──────────────── with sessions_lock: - to_remove = [] # (device_id,) — expired or over quota + to_remove = [] # (device_id,) — expired + to_throttle_add = [] # (device_id, uuid) — add to throttled box + to_throttle_cleanup = [] # (device_id,) — remove from normal box (delayed) for device_id, session in list(active_sessions.items()): @@ -448,12 +511,23 @@ def management_loop(): to_remove.append(device_id) continue - # ── 2. Check data quota — hard cut-off when exceeded ────── + # ── 2. Check throttle threshold ──────────────────────────── used_bytes = stats.get(device_id, {}).get("total_bytes", 0) - 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) + just_throttled = False + if used_bytes > THROTTLE_BYTES and not session.get("throttled", False): + print(f"[HIVEMIND] Throttling {device_id} ({used_bytes/1e9:.2f} GB used).") + session["throttled"] = True + just_throttled = True + to_throttle_add.append((device_id, session["vless_uuid"])) + + # ── 3. Delayed cleanup: remove from normal box ───────────── + # Only on the NEXT tick — app needs time to see the path + # change and gracefully reconnect first. + if (session.get("throttled") and not just_throttled + and not session.get("_cleaned_normal")): + to_throttle_cleanup.append(device_id) + session["_cleaned_normal"] = True for device_id in to_remove: del active_sessions[device_id] @@ -462,6 +536,14 @@ def management_loop(): for device_id in to_remove: remove_vless_client(device_id) + # Add to throttled box FIRST — app needs the UUID there to reconnect + for device_id, uuid in to_throttle_add: + add_vless_client(uuid, device_id, inbound_tag=VLESS_THROTTLED_TAG) + + # Then clean up the normal box — app already reconnected by now + for device_id in to_throttle_cleanup: + remove_vless_client(device_id, inbound_tag=VLESS_NORMAL_TAG) + except Exception as e: print(f"[HIVEMIND] Unhandled error: {e}") @@ -493,17 +575,17 @@ def run_cmd(cmd, ignore_errors=False): return None -def add_vless_client(uuid, email, level=0): +def add_vless_client(uuid, email, level=0, inbound_tag=VLESS_NORMAL_TAG): """Add a client UUID to the Xray inbound config and hot-reload. - level -- Xray policy level for bandwidth limits: - 0 = default. + level -- Xray policy level for bandwidth limits: 0 = default. + inbound_tag -- which inbound to add the client to (VLESS_NORMAL_TAG or VLESS_THROTTLED_TAG). """ with open(XRAY_CONFIG, 'r') as f: config = json.load(f) for inbound in config["inbounds"]: - if inbound.get("tag") == VLESS_INBOUND_TAG: + if inbound.get("tag") == inbound_tag: inbound["settings"]["clients"].append({ "id": uuid, "email": email, @@ -513,21 +595,26 @@ def add_vless_client(uuid, email, level=0): _atomic_write_json(XRAY_CONFIG, config) run_cmd("xray api adi --server=127.0.0.1:10085") - print(f"[VLESS] User added: {email[:16]}...") + print(f"[VLESS] User added to {inbound_tag}: {email[:16]}...") -def remove_vless_client(email): - """Remove a client from the Xray inbound config and hot-reload.""" +def remove_vless_client(email, inbound_tag=None): + """Remove a client from the Xray inbound config and hot-reload. + + inbound_tag -- if provided, only remove from that inbound. + if None, remove from ALL VLESS inbounds (cleanup). + """ with open(XRAY_CONFIG, 'r') as f: config = json.load(f) for inbound in config["inbounds"]: - if inbound.get("tag") == VLESS_INBOUND_TAG: + if inbound_tag and inbound.get("tag") != inbound_tag: + continue + if inbound.get("tag") in (VLESS_NORMAL_TAG, VLESS_THROTTLED_TAG, VLESS_INBOUND_TAG): clients = inbound["settings"]["clients"] inbound["settings"]["clients"] = [ c for c in clients if c.get("email") != email ] - break _atomic_write_json(XRAY_CONFIG, config) run_cmd("xray api adi --server=127.0.0.1:10085")