Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
571c07bca8 | ||
|
|
73d76c04f2 | ||
|
|
e751a9d6e4 |
No files matched your search
+49
-31
@@ -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
|
||||
|
||||
> **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
|
||||
- **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
|
||||
a support top-up moves it with the deadline. It does not promise an exact
|
||||
number of minutes: on a sleeping phone Android may hold it back, and the app
|
||||
would rather say "soon" than say "5 minutes" with one left.
|
||||
- **"Disconnected — not protected" notification** when a session ends without
|
||||
you asking: the 2-hour expiry, the data limit, the server ending the
|
||||
session, or the tunnel stopping unexpectedly.
|
||||
deadline it already enforces, so it arrives even with the app closed, and a
|
||||
support top-up moves it with the deadline. It does not promise an exact number
|
||||
of minutes: on a deeply sleeping phone Android can hold it back past the end
|
||||
of the session, in which case it is skipped rather than shown late.
|
||||
- **A "disconnected — not protected" notification** when a session ends without
|
||||
you asking: the 2-hour expiry, the data limit, or the server ending the
|
||||
session.
|
||||
- **SOCKS5 mode now asks for the notification permission.** It never did,
|
||||
because it never raises the VPN consent dialog, so on Android 13+ these
|
||||
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
|
||||
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
|
||||
VPN service's own deadline and by the server.
|
||||
VPN service's own deadline, which keeps running regardless.
|
||||
|
||||
### 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.
|
||||
- **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.
|
||||
- **The countdown no longer over-reports after the phone sleeps**, and a clock
|
||||
moved backwards can no longer stretch a session.
|
||||
- **A session that expired while the phone was asleep is recognised on waking**,
|
||||
by wall clock rather than by time the device spent awake.
|
||||
- **Disconnecting from Android's VPN settings no longer leaves a countdown in
|
||||
the notification shade.** It used to sit there frozen, showing time remaining
|
||||
on a tunnel that was already gone.
|
||||
- **The VPN reconnects itself after the tunnel core stops.** Recovery never
|
||||
worked: every attempt failed on the same internal error, and each one left
|
||||
another copy of the core running in the background. One restart now brings it
|
||||
back without dropping the connection.
|
||||
- **Disconnecting now releases your session on the server** instead of leaving
|
||||
it open until it expires on its own.
|
||||
- **A core that fails to start now says why.** Its output was being thrown
|
||||
away, so the only trace was an exit code.
|
||||
- **The VPN recovers after the tunnel core stops.** Recovery never worked:
|
||||
every attempt failed on the same internal error, and each one left another
|
||||
copy of the core running in the background. It now usually comes back on the
|
||||
first retry, without dropping the connection.
|
||||
- **Disconnecting with the app's own button now releases your session on the
|
||||
server** instead of leaving it open until it expires.
|
||||
- **SOCKS5 "Reconnect" re-attaches to the current session** instead of
|
||||
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
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) {
|
||||
|
||||
+1
-1
@@ -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.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
version: 3.3.7+33
|
||||
version: 3.3.8+34
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
@@ -116,7 +116,7 @@ void main() {
|
||||
// /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
|
||||
// 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 release = timer.indexOf('HivemindService.releaseSession()');
|
||||
expect(release, greaterThan(disconnectEntry));
|
||||
@@ -131,4 +131,37 @@ void main() {
|
||||
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',
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user