6 Commits
Author SHA1 Message Date
esefxdzandClaude Opus 5 f28379f73f Merge branch 'docs/privacy-policy-accuracy'
Android CI / android (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-18 13:47:36 +03:00
esefxdzandClaude Opus 5 8260c7cc0a docs(privacy): correct the policy the app actually links to
The sidebar opens this file, so it is the copy users read, and it had
drifted from the service it describes.

- the device ID is stored on the phone and reused between sessions; the
  policy implied it was per-session
- "we do not sell or share any data with third parties" sat in the same
  document as the AdMob section that explains Google collects data for
  ad delivery; narrowed to what is true
- dropped the service name and journalctl invocation from the log
  paragraph; operational detail nobody reading a privacy policy needs

Now byte-identical to the copy on the website.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-18 13:41:57 +03:00
esefxdzandClaude Opus 5 62e968f133 fix(app): stop probing the control plane while disconnected
Android CI / android (push) Canceled after 0s
VpnConnection._init() started a 30 s /health poll from the constructor
with no lifecycle gating, so it ran for the life of the app process -
backgrounded, and whether or not the user ever connected. The app is
excluded from its own tunnel, so those requests left in the clear. A
periodic request that fires while the user is deliberately not connected
is an avoidable fingerprint, and rule 60 already ruled probes out:
reachability is meant to be passive.

All it bought was the status dot before the first connect, and it got
that wrong in both directions - "Offline" on a transient resolver
failure against a server that was fine.

Reachability now comes from a session sync that succeeded. serverHealthy
is isConnected && hasSyncedOnce, so a green dot means a working session
rather than a check made beforehand; while disconnected the bar reads
"Ready" and claims nothing either way.

checkHealth() goes with it. Nothing calls it, and leaving a probe helper
in place invites its reuse.

Pinned in test/session_lifecycle_regression_test.dart so it cannot come
back as a tidy-up. flutter analyze clean, 10/10 tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-17 21:17:17 +03:00
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
9 changed files with 155 additions and 102 deletions

No files matched your search

+62 -31
View File
@@ -5,17 +5,52 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
--- ---
## [Unreleased]
### Removed
- **The background server check while disconnected.** The app no longer contacts
the server every 30 seconds while you are not connected to it. Nothing about
the connected session changes.
### Changed
- **The status bar no longer guesses whether the server is up.** It reads
"Ready" while disconnected and shows "Online" once a session has synced, so a
green dot now means a session that is actually working rather than a
reachability check made beforehand.
## [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 +63,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
+4 -4
View File
@@ -1,6 +1,6 @@
# Privacy Policy # Privacy Policy
**Last updated:** September 2026 **Last updated:** October 2026
ReVoltVPN is a free VPN service built and operated by ReVolt Team. ReVoltVPN is a free VPN service built and operated by ReVolt Team.
@@ -12,7 +12,7 @@ When you first open the app, we show a brief summary of this policy before you c
We collect the minimum required to operate the service: We collect the minimum required to operate the service:
- A randomly generated device ID created on your phone. This is not linked to your identity in any way. - A randomly generated device ID created on your phone. It is stored on your device and reused between sessions, and it is not linked to your identity in any way.
- Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below). - Your connection duration and data usage for the current session, linked to your device ID. This is used solely to enforce the per-session quota (see below).
## What our server sees ## What our server sees
@@ -27,14 +27,14 @@ Support ads (the "Support us" button) extend an active session by 30 minutes and
## Operational logs ## Operational logs
Server logs are written to the systemd journal (`journalctl -u hivemind`). These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's systemd journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device. Server logs are written to the system journal. These logs include truncated device IDs (first 8 characters only), session start/stop events, and error messages. Logs stay on the server and are accessible only to the server operator via local console; retention follows the server's journal settings. We also maintain an aggregate total of all data ever transferred (in GB) for capacity planning — this counter is not tied to any individual device.
## What we do not collect ## What we do not collect
- We do not collect your name, email, or any personal information. - We do not collect your name, email, or any personal information.
- We do not log your internet traffic, browsing history, DNS queries, or destination IPs. - We do not log your internet traffic, browsing history, DNS queries, or destination IPs.
- We do not inspect or store the content of your traffic. - We do not inspect or store the content of your traffic.
- We do not sell or share any data with third parties. - We do not sell your data. We do not share it with anyone, apart from what Google's ad SDK collects for ad delivery — see “Ads & Consent” below.
## Ads & Consent ## Ads & Consent
+6 -7
View File
@@ -49,15 +49,14 @@ class StatusBar extends StatelessWidget {
builder: (context, vpn, timer, _) { builder: (context, vpn, timer, _) {
final isConnected = vpn.status == VpnStatus.connected; final isConnected = vpn.status == VpnStatus.connected;
final isConnecting = vpn.status == VpnStatus.connecting; final isConnecting = vpn.status == VpnStatus.connecting;
final serverHealthy = // Rule 60: reachability is known only from a session sync that
vpn.serverReachable || (isConnected && timer.hasSyncedOnce); // succeeded, never from a probe. While disconnected the app knows
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable; // nothing about the server, so it claims nothing either way.
final serverHealthy = isConnected && timer.hasSyncedOnce;
final statusLabel = isConnected final statusLabel = isConnected
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…') ? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
: (isConnecting : (isConnecting ? 'Connecting…' : 'Ready');
? 'Connecting…'
: (vpn.serverReachable ? 'Ready' : 'Offline'));
final detail = _detail(vpn, timer); final detail = _detail(vpn, timer);
+9 -25
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);
} }
@@ -198,18 +197,6 @@ class HivemindService {
throw Exception('Session not activated. Server callback may have timed out.'); throw Exception('Session not activated. Server callback may have timed out.');
} }
static Future<bool> checkHealth() async {
try {
final response = await controlGet(
_publicUrl('/health'),
timeout: const Duration(seconds: 3),
);
return response.statusCode == 200;
} catch (_) {
return false;
}
}
static String newNonce() { static String newNonce() {
final random = Random.secure(); final random = Random.secure();
final high = random.nextInt(0x7FFFFFFF); final high = random.nextInt(0x7FFFFFFF);
@@ -229,10 +216,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 +231,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;
+6 -9
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 -20
View File
@@ -38,9 +38,6 @@ class VpnConnection extends ChangeNotifier {
bool _adoptedRunningRuntime = false; bool _adoptedRunningRuntime = false;
bool get adoptedRunningRuntime => _adoptedRunningRuntime; bool get adoptedRunningRuntime => _adoptedRunningRuntime;
bool _serverReachable = false;
bool get serverReachable => _serverReachable;
ConnectionMode _activeMode = ConnectionMode.tun; ConnectionMode _activeMode = ConnectionMode.tun;
ConnectionMode get activeMode => _activeMode; ConnectionMode get activeMode => _activeMode;
@@ -73,7 +70,6 @@ class VpnConnection extends ChangeNotifier {
); );
} }
Timer? _healthTimer;
StreamSubscription<NetworkSnapshot>? _networkSubscription; StreamSubscription<NetworkSnapshot>? _networkSubscription;
SecureSocksSession? _lastSecureSocks; SecureSocksSession? _lastSecureSocks;
@@ -142,11 +138,6 @@ class VpnConnection extends ChangeNotifier {
}, },
); );
unawaited(_checkHealth());
_healthTimer = Timer.periodic(const Duration(seconds: 30), (_) {
unawaited(_checkHealth());
});
try { try {
final coreVersion = await _vless.getCoreVersion(); final coreVersion = await _vless.getCoreVersion();
if (!_disposed) { if (!_disposed) {
@@ -200,7 +191,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 +257,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(
@@ -473,14 +463,6 @@ class VpnConnection extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> _checkHealth() async {
if (_disposed || _status == VpnStatus.connected) return;
final reachable = await HivemindService.checkHealth();
if (_disposed || _status == VpnStatus.connected) return;
_serverReachable = reachable;
notifyListeners();
}
@override @override
void dispose() { void dispose() {
_disposed = true; _disposed = true;
@@ -488,7 +470,6 @@ class VpnConnection extends ChangeNotifier {
_suppressNativeConnect = true; _suppressNativeConnect = true;
_userDisconnecting = true; _userDisconnecting = true;
HivemindService.cancel(); HivemindService.cancel();
_healthTimer?.cancel();
_networkSubscription?.cancel(); _networkSubscription?.cancel();
// Provider/UI disposal is not a user disconnect command. Keeping teardown // Provider/UI disposal is not a user disconnect command. Keeping teardown
// in disconnect() prevents lifecycle churn from silently dropping the VPN. // in disconnect() prevents lifecycle churn from silently dropping the VPN.
@@ -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 (await vpn.connect(adNonce: adNonce)) {
if (!mounted) return; if (!mounted) return;
await timer.start(resetSupportClaim: false); 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"
+54 -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,57 @@ 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',
);
});
test('nothing probes the control plane while disconnected - rule 60', () {
final vpn = File('lib/logic/vpn_connection.dart').readAsStringSync();
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
final bar = File('lib/components/status_bar.dart').readAsStringSync();
// The runtime excludes the app from its own tunnel
// (XrayVPNService.kt addDisallowedApplication), so a periodic /health probe
// was a cleartext-SNI metronome to the API host - which resolves to the
// tunnel IP. It fed step 1 of the kill chain in KNOWN_SECURITY_ISSUES #3,
// in exchange for a green dot before the user had connected to anything.
expect(vpn, isNot(contains('_healthTimer')));
expect(vpn, isNot(contains('_checkHealth')));
expect(hivemind, isNot(contains('checkHealth')));
expect(hivemind, isNot(contains("'/health'")));
// Reachability is passive now: a session sync that succeeded, nothing else.
expect(bar, isNot(contains('serverReachable')));
expect(bar, contains('isConnected && timer.hasSyncedOnce'));
});
} }