From 37753c63963e7d349e35b1ef397173058e10ed37 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Fri, 16 Oct 2026 23:51:19 +0300 Subject: [PATCH] fix(ads): pass the rewarded ad's nonce through to connect With ads enabled the SSV nonce and the nonce the connect flow waited for were minted separately, so the client rejected the very session the ad had just created and failed with "Session not activated". - showAd() returns the nonce it put in the SSV custom_data (null on no reward) - fetchConfigDirectly({adNonce, resume}) replaces skipAdBypass: a real ad waits for exactly that nonce and polls ~36 s for Google's callback, the bypass path mints its own, and resume re-attaches to the session the device already owns - SOCKS5 "Reconnect" uses resume: true instead of requesting a new session Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 ++++++++ lib/components/connect_button.dart | 6 +-- lib/components/support_button.dart | 2 +- lib/logic/ad_manager.dart | 35 ++++++++--------- lib/logic/hivemind_service.dart | 38 +++++++++++-------- lib/logic/vpn_connection.dart | 5 ++- .../settings/in_settings/connection_mode.dart | 10 ++--- 7 files changed, 68 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fda24c1..4f4f6ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH --- +## [Unreleased] + +### Fixed +- **Rewarded ads no longer break connecting.** With ads enabled, the ad's + server-side-verification nonce and the nonce the connect flow waited for were + minted separately, so the client rejected the very session the ad created and + failed with "Session not activated". The ad's nonce is now passed through to + the connect call, the fake bypass callback is skipped when a real ad was + watched, and the client polls longer (~36 s) for Google's callback. +- **SOCKS5 "Reconnect" re-attaches to the current session** instead of + requesting a new one, so repairing a lost proxy no longer needs (or grants) + a fresh session. + +--- + ## [3.3.6] — 2026-10-08 ### Fixed diff --git a/lib/components/connect_button.dart b/lib/components/connect_button.dart index 60227d2..93cc9f9 100644 --- a/lib/components/connect_button.dart +++ b/lib/components/connect_button.dart @@ -95,10 +95,10 @@ class _ConnectButtonState extends State setState(() => _busy = true); try { - final adWatched = await ad.showAd('main'); - if (!adWatched && AdManager.adsEnabled) return; + final adNonce = await ad.showAd('main'); + if (adNonce == null && AdManager.adsEnabled) return; - final ok = await vpn.connect(); + final ok = await vpn.connect(adNonce: adNonce); if (ok) { await timer.start(); HapticSettings.success(); diff --git a/lib/components/support_button.dart b/lib/components/support_button.dart index 4565016..ac1d3ab 100644 --- a/lib/components/support_button.dart +++ b/lib/components/support_button.dart @@ -31,7 +31,7 @@ class _SupportButtonState extends State { setState(() => _busy = true); try { - final rewarded = await ad.showAd('support'); + final rewarded = await ad.showAd('support') != null; if (rewarded) { await timer.markSupportRewardClaimed(); await timer.syncNow(); diff --git a/lib/logic/ad_manager.dart b/lib/logic/ad_manager.dart index 51a8fe7..7e0d225 100644 --- a/lib/logic/ad_manager.dart +++ b/lib/logic/ad_manager.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:revoltvpn/logic/crypto_service.dart'; @@ -97,13 +96,20 @@ class AdManager extends ChangeNotifier { // ── Show ad (or debug bypass) ───────────────────────────────────── - Future showAd(String adType) async { - // Debug bypass: fire fake AdMob callback so support ads work in dev. - // The server's ADMOB_BYPASS must be True for this to succeed. - if (!adsEnabled && kDebugMode) { + // Returns the nonce carried in the SSV custom_data when the reward was + // earned, null otherwise. The main flow passes it to vpn.connect(adNonce:) + // so the client waits for the session this ad created — minting a second + // nonce there is what broke every connect once ads were on. + Future showAd(String adType) async { + if (!adsEnabled) { + // Main connect: no ad to show — HivemindService.fetchConfigDirectly() + // fires the server-gated bypass itself (rule 87). + if (adType != 'support' || !kDebugMode) return null; + + // Debug bypass: fire fake AdMob callback so support ads work in dev. + // The server's ADMOB_BYPASS must be True for this to succeed. final deviceId = await CryptoService.getDeviceId(); - final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}'; - HivemindService.setExpectedNonce(nonce); + final nonce = HivemindService.newNonce(); try { final customData = jsonEncode({ 'device_id': deviceId, @@ -115,29 +121,24 @@ class AdManager extends ChangeNotifier { '?signature=test&key_id=test' '&custom_data=${Uri.encodeComponent(customData)}'); await HivemindService.directGet(fakeUrl, timeout: const Duration(seconds: 8)); - return true; + return nonce; } catch (_) { - return false; + return null; } } - if (!adsEnabled) return false; - await ensureSdkInitialized(); if (!_isAdLoaded || _rewardedAd == null) { final loaded = await preloadAd(); if (!loaded || _rewardedAd == null) { debugPrint('[AdManager] Cannot show ad, failed to load.'); - return false; + return null; } } final deviceId = await CryptoService.getDeviceId(); - - final nonce = '${Random().nextInt(0x7FFFFFFF)}-${DateTime.now().millisecondsSinceEpoch}'; - HivemindService.setExpectedNonce(nonce); - debugPrint('[AdManager] Ad nonce: $nonce'); + final nonce = HivemindService.newNonce(); final ssvOptions = ServerSideVerificationOptions( customData: jsonEncode({ @@ -182,7 +183,7 @@ class AdManager extends ChangeNotifier { }, ); - return rewardCompleter.future; + return await rewardCompleter.future ? nonce : null; } @override diff --git a/lib/logic/hivemind_service.dart b/lib/logic/hivemind_service.dart index 3791d2b..ac1daea 100644 --- a/lib/logic/hivemind_service.dart +++ b/lib/logic/hivemind_service.dart @@ -81,13 +81,19 @@ class HivemindService { _expectedNonce = null; } - static void setExpectedNonce(String nonce) { - _expectedNonce = nonce; - } - + // Fetches this device's VLESS URL. Three ways in: + // adNonce != null — a real rewarded ad was watched; its SSV custom_data + // carried this nonce, so wait for a session holding it. + // Google's SSV callback can lag, so poll longer. + // resume == true — re-attach to the session already granted (mode-switch + // reconnect in connection_mode.dart). No ad, no bypass, + // any nonce accepted — device_id already owns it. + // neither — ads disabled: mint a nonce and fire the server-gated + // ADMOB_BYPASS callback (rule 87). static Future fetchConfigDirectly({ void Function(int attempt, int total)? onAttempt, - bool skipAdBypass = false, + String? adNonce, + bool resume = false, }) async { // Capture the generation before the first await. Otherwise a disconnect // that lands while secure storage is resolving the device ID can be lost @@ -96,15 +102,15 @@ class HivemindService { final deviceId = await CryptoService.getDeviceId(); _throwIfCancelled(callId); - final nonce = _newNonce(); + final nonce = resume ? null : (adNonce ?? newNonce()); _expectedNonce = nonce; - if (!skipAdBypass) { - await _runConfiguredBypass(deviceId, nonce); + if (!resume && adNonce == null) { + await _runConfiguredBypass(deviceId, nonce!); _throwIfCancelled(callId); } - const maxAttempts = 5; + final maxAttempts = adNonce != null ? 10 : 5; for (var attempt = 1; attempt <= maxAttempts; attempt++) { _throwIfCancelled(callId); onAttempt?.call(attempt, maxAttempts); @@ -122,7 +128,9 @@ class HivemindService { } if (attempt < maxAttempts) { - await Future.delayed(_pollBackoff[attempt - 1]); + await Future.delayed( + _pollBackoff[min(attempt - 1, _pollBackoff.length - 1)], + ); _throwIfCancelled(callId); } } @@ -142,7 +150,7 @@ class HivemindService { } } - static String _newNonce() { + static String newNonce() { final random = Random.secure(); final high = random.nextInt(0x7FFFFFFF); final low = random.nextInt(0x7FFFFFFF); @@ -161,9 +169,10 @@ class HivemindService { } } + // nonce == null accepts whatever nonce the server holds (resume path). static Future<_HivemindSessionConfig?> _fetchActiveSession( String deviceId, - String nonce, + String? nonce, ) async { final statusUri = _publicUrl('/session/status').replace( queryParameters: {'device_id': deviceId}, @@ -175,9 +184,8 @@ class HivemindService { if (decoded is! Map) return null; final serverNonce = decoded['nonce']; - if (_expectedNonce != nonce || - serverNonce is! String || - serverNonce != nonce) { + if (_expectedNonce != nonce) return null; + if (nonce != null && (serverNonce is! String || serverNonce != nonce)) { return null; } diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 17368e4..44d0e30 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -200,7 +200,7 @@ class VpnConnection extends ChangeNotifier { } } - Future connect({bool skipAdBypass = false}) async { + Future connect({String? adNonce, bool resume = false}) async { if (_disposed || _userDisconnecting || _status == VpnStatus.connected || @@ -254,7 +254,8 @@ class VpnConnection extends ChangeNotifier { String realUrl; try { realUrl = await HivemindService.fetchConfigDirectly( - skipAdBypass: skipAdBypass, + adNonce: adNonce, + resume: resume, onAttempt: (attempt, total) { if (!_isCurrentConnect(connectEpoch)) return; _setStatus( diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index 46519d7..c638e66 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -67,10 +67,10 @@ class _ConnectionModeTileState extends State { widget.onChanged?.call(next); } - /// Recovers from an orphaned proxy: tear the runtime down and start a fresh - /// session so a readable set of credentials exists again. No rewarded ad — - /// the user is repairing a connection they already paid for, not opening a - /// new one. + // Recovers from an orphaned proxy: tear the runtime down and re-attach to + // the session this device already holds (connect(resume: true)), so a + // readable set of credentials exists again. No rewarded ad and no new + // session — the user is repairing a connection they already paid for. Future _reconnect() async { if (_reconnecting) return; final vpn = context.read(); @@ -80,7 +80,7 @@ class _ConnectionModeTileState extends State { try { await timer.disconnect(); if (!mounted) return; - if (await vpn.connect()) { + if (await vpn.connect(resume: true)) { if (!mounted) return; await timer.start(); }