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 <[email protected]>
This commit is contained in:
1 parent
e14e900276
commit
37753c6396
7 files changed
+68
-43
No files matched your search
@@ -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
|
||||
|
||||
@@ -95,10 +95,10 @@ class _ConnectButtonState extends State<ConnectButton>
|
||||
|
||||
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();
|
||||
|
||||
@@ -31,7 +31,7 @@ class _SupportButtonState extends State<SupportButton> {
|
||||
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();
|
||||
|
||||
+18
-17
@@ -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<bool> 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<String?> 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
|
||||
|
||||
@@ -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<String> 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<String, dynamic>) 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> connect({bool skipAdBypass = false}) async {
|
||||
Future<bool> 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(
|
||||
|
||||
@@ -67,10 +67,10 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
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<void> _reconnect() async {
|
||||
if (_reconnecting) return;
|
||||
final vpn = context.read<VpnConnection>();
|
||||
@@ -80,7 +80,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
|
||||
try {
|
||||
await timer.disconnect();
|
||||
if (!mounted) return;
|
||||
if (await vpn.connect()) {
|
||||
if (await vpn.connect(resume: true)) {
|
||||
if (!mounted) return;
|
||||
await timer.start();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user