feat(session): every reconnect starts a fresh session
Android CI / android (push) Canceled after 0s

SOCKS5 Reconnect re-attached to the session the device already held. It
was the only path where a session began without an ad and without a
nonce binding it to one, and supporting it cost three parameters -
resume, release, resetSupportClaim - plus a carve-out in rule 4 that let
_fetchActiveSession accept whatever nonce the server happened to return.

It also wasn't the kindness it looked like. _handle_main calls
create_or_replace with a fresh full window, so a new session hands the
user *more* time, not less; re-attaching only preserved the remainder of
a session whose proxy had just died.

Reconnect now disconnects and connects again exactly as the button does,
ad included. All four special cases are deleted and the nonce check is
unconditional.

Costs one rewarded ad to repair an orphaned proxy once ads are enabled.
esef's call, and it buys a single way to start a session.

Also fixes the 3.3.7 regression where Reconnect released the session and
then asked for it back: there is no longer a path that does either.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
esefxdzandClaude Opus 5 committed 2026-10-17 18:53:42 +03:00
1 parent 73d76c04f2
commit 571c07bca8
7 files changed
+75 -51

No files matched your search

+9 -13
View File
@@ -146,14 +146,13 @@ class HivemindService {
_expectedNonce = null;
}
// Fetches this device's VLESS URL.
// adNonce — wait for the session that ad created (SSV can lag, so poll longer)
// resume — re-attach to the session already held; any nonce accepted
// neither — ads off: mint a nonce and fire the bypass (rule 87)
// Fetches this device's VLESS URL. Every session starts the same way, so
// there is always a nonce to bind it to: either the one the ad minted, or one
// we mint here before firing the bypass (rule 87). The re-attach path that
// used to accept any nonce is gone - see rule 4.
static Future<String> fetchConfigDirectly({
void Function(int attempt, int total)? onAttempt,
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
@@ -162,11 +161,11 @@ class HivemindService {
final deviceId = await CryptoService.getDeviceId();
_throwIfCancelled(callId);
final nonce = resume ? null : (adNonce ?? newNonce());
final nonce = adNonce ?? newNonce();
_expectedNonce = nonce;
if (!resume && adNonce == null) {
await _runConfiguredBypass(deviceId, nonce!);
if (adNonce == null) {
await _runConfiguredBypass(deviceId, nonce);
_throwIfCancelled(callId);
}
@@ -229,10 +228,9 @@ 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},
@@ -245,9 +243,7 @@ class HivemindService {
final serverNonce = decoded['nonce'];
if (_expectedNonce != nonce) return null;
if (nonce != null && (serverNonce is! String || serverNonce != nonce)) {
return null;
}
if (serverNonce is! String || serverNonce != nonce) return null;
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
return null;
+8 -17
View File
@@ -158,9 +158,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
}
// resetSupportClaim is false when re-attaching to a session already held:
// the top-up is once per server session and this flag is what enforces it.
Future<void> start({bool resetSupportClaim = true}) async {
Future<void> start() async {
_sessionEpoch++;
_remainingSeconds = 0;
_remainingAtLastSync = 0;
@@ -178,12 +176,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_lastEndMessage = null;
NotificationService.reset();
if (resetSupportClaim) {
_supportStateEpoch++;
_supportRewardClaimed = false;
_supportRewardStateLoaded = true;
unawaited(_persistSupportRewardState(false));
}
// Every session is new, so the once-per-session top-up resets with it.
_supportStateEpoch++;
_supportRewardClaimed = false;
_supportRewardStateLoaded = true;
unawaited(_persistSupportRewardState(false));
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
@@ -240,17 +237,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
}
// release is false when the caller intends to re-attach to the same session,
// as the SOCKS5 Reconnect does: handing it back here would destroy the very
// session the follow-up connect(resume: true) is about to ask for.
Future<void> disconnect({
String reason = 'User requested',
bool release = true,
}) async {
Future<void> disconnect({String reason = 'User requested'}) async {
// Only here, not in _doDisconnect: this is the one path the user actually
// asked for. Unawaited so a slow or unreachable API never holds up the
// teardown.
if (release) unawaited(HivemindService.releaseSession());
unawaited(HivemindService.releaseSession());
await _doDisconnect(reason);
}
+1 -2
View File
@@ -200,7 +200,7 @@ class VpnConnection extends ChangeNotifier {
}
}
Future<bool> connect({String? adNonce, bool resume = false}) async {
Future<bool> connect({String? adNonce}) async {
if (_disposed ||
_userDisconnecting ||
_status == VpnStatus.connected ||
@@ -266,7 +266,6 @@ class VpnConnection extends ChangeNotifier {
try {
realUrl = await HivemindService.fetchConfigDirectly(
adNonce: adNonce,
resume: resume,
onAttempt: (attempt, total) {
if (!_isCurrentConnect(connectEpoch)) return;
_setStatus(
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/ad_manager.dart';
import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart';
@@ -67,20 +68,27 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
widget.onChanged?.call(next);
}
// Recovers an orphaned proxy: tear the runtime down and re-attach to the
// session already held. No ad, no new session.
// Recovers an orphaned proxy by ending the session and starting a new one,
// exactly as the connect button does. Re-attaching to the session already
// held was the one place a session began without an ad and without a nonce
// to bind it to; a fresh one is the same path everywhere and hands back a
// full window rather than the remainder of a broken one.
Future<void> _reconnect() async {
if (_reconnecting) return;
final vpn = context.read<VpnConnection>();
final timer = context.read<SessionTimer>();
final ad = context.read<AdManager>();
setState(() => _reconnecting = true);
try {
await timer.disconnect(release: false);
await timer.disconnect();
if (!mounted) return;
if (await vpn.connect(resume: true)) {
final adNonce = await ad.showAd('main');
if (adNonce == null && AdManager.adsEnabled) return;
if (!mounted) return;
if (await vpn.connect(adNonce: adNonce)) {
if (!mounted) return;
await timer.start(resetSupportClaim: false);
await timer.start();
}
} finally {
if (mounted) {