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(); }