3 Commits
Author SHA1 Message Date
esefxdzandClaude Opus 5 571c07bca8 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]>
2026-10-17 18:53:42 +03:00
esefxdzandClaude Opus 5 73d76c04f2 docs(changelog): correct 3.3.7, which advertised behaviour it does not have
Android CI / android (push) Canceled after 0s
A Fable review checked every entry against the commits in the release and
found the SOCKS5 Reconnect bullet is false for the shipped binary: tag
v3.3.7 contains e67f0b9 (/session/stop) but not e751a9d (the fix), so in
that build Reconnect ends the session instead of re-attaching. The public
changelog promised the opposite. It now carries a Known issue box naming
the symptom and the workaround.

Also corrected, same review:

- The warning's "now actually arrives when the app is closed" named the
  sleeping-phone case and claimed to fix it. It does not: the backstop is
  Handler.postDelayed on uptimeMillis, which stalls under true suspend,
  and a late delivery is then dropped by the window check - so the
  failure mode there is no warning, not a late one. The evidence was an
  emulator under forced idle, which never suspends the CPU. Dropped the
  Fixed entry (the feature is new in 3.3.7 anyway, so it fixed no
  released bug) and let the honest Added bullet carry it.
- /session/stop fires only from the app's own disconnect button, not on
  expiry, cap, revoke or a kill. Scoped the claim.
- The ad entry moved to Groundwork: ads are off in this build and the
  path was reviewed, never exercised against a real SSV callback.
- "One restart now brings it back" rested on one emulator run killing a
  warm core, and the cold-start exit-23 is unfixed. Softened.
- Core recovery "says why" -> logs why; no user sees it.
- Added the two countdown fixes from d1ea13b and 34722c7, which were
  user-visible and had no entry at all.
- Dropped "the tunnel stopping unexpectedly": that path never reaches
  stopAll, so no alert is posted for it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-17 18:22:19 +03:00
esefxdzandClaude Opus 5 e751a9d6e4 fix(session): don't release the session on a SOCKS5 Reconnect
Android CI / android (push) Canceled after 0s
Regression from e67f0b9, shipped in 3.3.7.

Reconnect repairs an orphaned proxy by tearing the runtime down and
re-attaching to the session the device already holds. It does that
through SessionTimer.disconnect(), which e67f0b9 taught to hand the
session back to the server - so it destroyed the very session the
following connect(resume: true) then asked for. Unawaited, so it raced:
the resume either found the session already gone or removed moments
later, surfacing as "Session not activated".

disconnect() now takes release, and the Reconnect path passes false.
Every other caller still hands the session back.

Narrow in practice: SOCKS5 mode only, only the Settings Reconnect
button, and the main-screen connect was unaffected because it mints a
new session anyway.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-17 17:17:29 +03:00
7 changed files with 108 additions and 57 deletions

No files matched your search

+49 -31
View File
@@ -5,17 +5,39 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
--- ---
## [3.3.8] — 2026-10-17
### Changed
- **Reconnecting always starts a fresh session.** In SOCKS5 mode, the
**Reconnect** button used to re-attach to the session you already had. It now
ends that session and starts a new one, exactly as the connect button does —
one way to begin a session instead of two, and you get a full window back
rather than the remainder of a broken one.
### Fixed
- **SOCKS5 "Reconnect" works again.** In 3.3.7 it ended your session and then
asked for the session it had just ended, failing with "Session not activated".
---
## [3.3.7] — 2026-10-17 ## [3.3.7] — 2026-10-17
> **Known issue — fixed in 3.3.8.** In SOCKS5 mode, the **Reconnect**
> button in Settings ends your session instead of re-attaching to it, and then
> fails with "Session not activated". It only appears when a proxy session has
> been orphaned, and you lose the rest of that session rather than anything
> worse. Workaround: connect again from the main screen. The "SOCKS5 Reconnect"
> entry below describes the intended behaviour, which this build does not have.
### Added ### Added
- **A warning before a session ends.** The VPN service schedules it off the same - **A warning before a session ends.** The VPN service schedules it off the same
deadline it already enforces, so it still arrives when the app is closed, and deadline it already enforces, so it arrives even with the app closed, and a
a support top-up moves it with the deadline. It does not promise an exact support top-up moves it with the deadline. It does not promise an exact number
number of minutes: on a sleeping phone Android may hold it back, and the app of minutes: on a deeply sleeping phone Android can hold it back past the end
would rather say "soon" than say "5 minutes" with one left. of the session, in which case it is skipped rather than shown late.
- **"Disconnected — not protected" notification** when a session ends without - **A "disconnected — not protected" notification** when a session ends without
you asking: the 2-hour expiry, the data limit, the server ending the you asking: the 2-hour expiry, the data limit, or the server ending the
session, or the tunnel stopping unexpectedly. session.
- **SOCKS5 mode now asks for the notification permission.** It never did, - **SOCKS5 mode now asks for the notification permission.** It never did,
because it never raises the VPN consent dialog, so on Android 13+ these because it never raises the VPN consent dialog, so on Android 13+ these
alerts would have been dropped before they were shown. alerts would have been dropped before they were shown.
@@ -28,36 +50,32 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
to disconnect itself after about two minutes of failed status polls, putting to disconnect itself after about two minutes of failed status polls, putting
you back on your real IP; it now keeps the tunnel up and shows you back on your real IP; it now keeps the tunnel up and shows
"Can't reach the server — still connected". Expiry is still enforced by the "Can't reach the server — still connected". Expiry is still enforced by the
VPN service's own deadline and by the server. VPN service's own deadline, which keeps running regardless.
### Fixed ### Fixed
- **Rewarded ads no longer break connecting.** With ads enabled, the ad's - **The countdown no longer over-reports after the phone sleeps**, and a clock
server-side-verification nonce and the nonce the connect flow waited for were moved backwards can no longer stretch a session.
minted separately, so the client rejected the very session the ad created and - **A session that expired while the phone was asleep is recognised on waking**,
failed with "Session not activated". The ad's nonce is now passed through to by wall clock rather than by time the device spent awake.
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.
- **The end-of-session warning now actually arrives when the app is closed.**
It was scheduled only on an inexact alarm, and a sleeping phone could hold
that alarm back past the end of the session it was warning about — so in the
one case the warning exists for, it never came. It now also runs off the VPN
service's own timer, the same one that already ends the session on time.
- **Disconnecting from Android's VPN settings no longer leaves a countdown in - **Disconnecting from Android's VPN settings no longer leaves a countdown in
the notification shade.** It used to sit there frozen, showing time remaining the notification shade.** It used to sit there frozen, showing time remaining
on a tunnel that was already gone. on a tunnel that was already gone.
- **The VPN reconnects itself after the tunnel core stops.** Recovery never - **The VPN recovers after the tunnel core stops.** Recovery never worked:
worked: every attempt failed on the same internal error, and each one left every attempt failed on the same internal error, and each one left another
another copy of the core running in the background. One restart now brings it copy of the core running in the background. It now usually comes back on the
back without dropping the connection. first retry, without dropping the connection.
- **Disconnecting now releases your session on the server** instead of leaving - **Disconnecting with the app's own button now releases your session on the
it open until it expires on its own. server** instead of leaving it open until it expires.
- **A core that fails to start now says why.** Its output was being thrown - **SOCKS5 "Reconnect" re-attaches to the current session** instead of
away, so the only trace was an exit code. requesting a new one — see the known issue above; this does not work in 3.3.7.
- **A core that fails to start now logs why.** Its output was being discarded,
so the only trace was an exit code.
--- ### Groundwork
- **Rewarded-ad plumbing.** Ads are switched off in this build, so none of this
is reachable yet: the ad's verification nonce is now carried through to the
connect call rather than being minted twice, and the client waits longer
(~36 s) for Google's callback. Reviewed, not yet exercised end to end.
## [3.3.6] — 2026-10-08 ## [3.3.6] — 2026-10-08
+9 -13
View File
@@ -146,14 +146,13 @@ class HivemindService {
_expectedNonce = null; _expectedNonce = null;
} }
// Fetches this device's VLESS URL. // Fetches this device's VLESS URL. Every session starts the same way, so
// adNonce — wait for the session that ad created (SSV can lag, so poll longer) // there is always a nonce to bind it to: either the one the ad minted, or one
// resume — re-attach to the session already held; any nonce accepted // we mint here before firing the bypass (rule 87). The re-attach path that
// neither — ads off: mint a nonce and fire the bypass (rule 87) // used to accept any nonce is gone - see rule 4.
static Future<String> fetchConfigDirectly({ static Future<String> fetchConfigDirectly({
void Function(int attempt, int total)? onAttempt, void Function(int attempt, int total)? onAttempt,
String? adNonce, String? adNonce,
bool resume = false,
}) async { }) async {
// Capture the generation before the first await. Otherwise a disconnect // Capture the generation before the first await. Otherwise a disconnect
// that lands while secure storage is resolving the device ID can be lost // that lands while secure storage is resolving the device ID can be lost
@@ -162,11 +161,11 @@ class HivemindService {
final deviceId = await CryptoService.getDeviceId(); final deviceId = await CryptoService.getDeviceId();
_throwIfCancelled(callId); _throwIfCancelled(callId);
final nonce = resume ? null : (adNonce ?? newNonce()); final nonce = adNonce ?? newNonce();
_expectedNonce = nonce; _expectedNonce = nonce;
if (!resume && adNonce == null) { if (adNonce == null) {
await _runConfiguredBypass(deviceId, nonce!); await _runConfiguredBypass(deviceId, nonce);
_throwIfCancelled(callId); _throwIfCancelled(callId);
} }
@@ -229,10 +228,9 @@ class HivemindService {
} }
} }
// nonce == null accepts whatever nonce the server holds (resume path).
static Future<_HivemindSessionConfig?> _fetchActiveSession( static Future<_HivemindSessionConfig?> _fetchActiveSession(
String deviceId, String deviceId,
String? nonce, String nonce,
) async { ) async {
final statusUri = _publicUrl('/session/status').replace( final statusUri = _publicUrl('/session/status').replace(
queryParameters: {'device_id': deviceId}, queryParameters: {'device_id': deviceId},
@@ -245,9 +243,7 @@ class HivemindService {
final serverNonce = decoded['nonce']; final serverNonce = decoded['nonce'];
if (_expectedNonce != nonce) return null; if (_expectedNonce != nonce) return null;
if (nonce != null && (serverNonce is! String || serverNonce != nonce)) { if (serverNonce is! String || serverNonce != nonce) return null;
return null;
}
if (decoded['active'] != true || decoded['vless_uuid'] == null) { if (decoded['active'] != true || decoded['vless_uuid'] == null) {
return null; return null;
+2 -5
View File
@@ -158,9 +158,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
} }
} }
// resetSupportClaim is false when re-attaching to a session already held: Future<void> start() async {
// the top-up is once per server session and this flag is what enforces it.
Future<void> start({bool resetSupportClaim = true}) async {
_sessionEpoch++; _sessionEpoch++;
_remainingSeconds = 0; _remainingSeconds = 0;
_remainingAtLastSync = 0; _remainingAtLastSync = 0;
@@ -178,12 +176,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_lastEndMessage = null; _lastEndMessage = null;
NotificationService.reset(); NotificationService.reset();
if (resetSupportClaim) { // Every session is new, so the once-per-session top-up resets with it.
_supportStateEpoch++; _supportStateEpoch++;
_supportRewardClaimed = false; _supportRewardClaimed = false;
_supportRewardStateLoaded = true; _supportRewardStateLoaded = true;
unawaited(_persistSupportRewardState(false)); unawaited(_persistSupportRewardState(false));
}
_timer?.cancel(); _timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick); _timer = Timer.periodic(const Duration(seconds: 1), _tick);
+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 || if (_disposed ||
_userDisconnecting || _userDisconnecting ||
_status == VpnStatus.connected || _status == VpnStatus.connected ||
@@ -266,7 +266,6 @@ class VpnConnection extends ChangeNotifier {
try { try {
realUrl = await HivemindService.fetchConfigDirectly( realUrl = await HivemindService.fetchConfigDirectly(
adNonce: adNonce, adNonce: adNonce,
resume: resume,
onAttempt: (attempt, total) { onAttempt: (attempt, total) {
if (!_isCurrentConnect(connectEpoch)) return; if (!_isCurrentConnect(connectEpoch)) return;
_setStatus( _setStatus(
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:revoltvpn/logic/ad_manager.dart';
import 'package:revoltvpn/logic/app_colors.dart'; import 'package:revoltvpn/logic/app_colors.dart';
import 'package:revoltvpn/logic/connection_settings.dart'; import 'package:revoltvpn/logic/connection_settings.dart';
import 'package:revoltvpn/logic/local_socks_tester.dart'; import 'package:revoltvpn/logic/local_socks_tester.dart';
@@ -67,20 +68,27 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
widget.onChanged?.call(next); widget.onChanged?.call(next);
} }
// Recovers an orphaned proxy: tear the runtime down and re-attach to the // Recovers an orphaned proxy by ending the session and starting a new one,
// session already held. No ad, no new session. // 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 { Future<void> _reconnect() async {
if (_reconnecting) return; if (_reconnecting) return;
final vpn = context.read<VpnConnection>(); final vpn = context.read<VpnConnection>();
final timer = context.read<SessionTimer>(); final timer = context.read<SessionTimer>();
final ad = context.read<AdManager>();
setState(() => _reconnecting = true); setState(() => _reconnecting = true);
try { try {
await timer.disconnect(); await timer.disconnect();
if (!mounted) return; 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 (!mounted) return;
await timer.start(resetSupportClaim: false); if (await vpn.connect(adNonce: adNonce)) {
if (!mounted) return;
await timer.start();
} }
} finally { } finally {
if (mounted) { if (mounted) {
+1 -1
View File
@@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 3.3.7+33 version: 3.3.8+34
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.0.0 <4.0.0"
+34 -1
View File
@@ -116,7 +116,7 @@ void main() {
// /session/stop takes a device_id as its only proof of ownership, so it // /session/stop takes a device_id as its only proof of ownership, so it
// must never be reachable from an error path. disconnect() is called only // must never be reachable from an error path. disconnect() is called only
// by the connect button; _doDisconnect is every other ending. // by the connect button; _doDisconnect is every other ending.
final disconnectEntry = timer.indexOf('Future<void> disconnect('); final disconnectEntry = timer.indexOf('Future<void> disconnect({');
final doDisconnect = timer.indexOf('Future<void> _doDisconnect('); final doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
final release = timer.indexOf('HivemindService.releaseSession()'); final release = timer.indexOf('HivemindService.releaseSession()');
expect(release, greaterThan(disconnectEntry)); expect(release, greaterThan(disconnectEntry));
@@ -131,4 +131,37 @@ void main() {
reason: 'exactly one call site', reason: 'exactly one call site',
); );
}); });
test('every session starts the same way - no re-attach path', () {
final mode =
File('lib/screens/settings/in_settings/connection_mode.dart')
.readAsStringSync();
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
// SOCKS5 Reconnect used to re-attach to the session already held. It was
// the only place a session began without an ad and without a nonce binding
// it, and it needed three parameters and a carve-out in rule 4 to support.
// Reconnect now ends the session and starts a fresh one, like the button.
expect(mode, contains("ad.showAd('main')"));
expect(mode, contains('vpn.connect(adNonce: adNonce)'));
expect(mode, isNot(contains('resume: true')));
expect(mode, isNot(contains('release: false')));
expect(mode, isNot(contains('resetSupportClaim')));
// The special cases that only existed for it are gone.
expect(hivemind, isNot(contains('bool resume')));
expect(vpn, isNot(contains('bool resume')));
expect(timer, isNot(contains('bool release')));
expect(timer, isNot(contains('resetSupportClaim')));
// So the nonce is now unconditional: no path accepts whatever the server
// happens to hold.
expect(
hivemind,
contains('if (serverNonce is! String || serverNonce != nonce) return null;'),
reason: 'the nonce check must not be skippable',
);
});
} }