Compare commits
24
Commits
v3.3.6
...
f28379f73f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f28379f73f | ||
|
|
8260c7cc0a | ||
|
|
62e968f133 | ||
|
|
571c07bca8 | ||
|
|
73d76c04f2 | ||
|
|
e751a9d6e4 | ||
|
|
c8617835b1 | ||
|
|
550402a663 | ||
|
|
52e8cd8ca1 | ||
|
|
437d998663 | ||
|
|
e67f0b93ee | ||
|
|
00b84dde61 | ||
|
|
296b087db6 | ||
|
|
eab43b0420 | ||
|
|
e7ef251882 | ||
|
|
4e5c7a02db | ||
|
|
30d96a4049 | ||
|
|
66789d49c1 | ||
|
|
8ba3b6c543 | ||
|
|
d1ea13b01a | ||
|
|
34722c751b | ||
|
|
dd1082c8de | ||
|
|
742f37683e | ||
|
|
37753c6396 |
No files matched your search
@@ -5,6 +5,91 @@ 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
|
||||
|
||||
> **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 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.
|
||||
- **The app now shows what went wrong.** Connect errors, the reason a session
|
||||
ended, and progress while connecting appear under the status bar instead of
|
||||
the button silently going back to idle.
|
||||
|
||||
### Changed
|
||||
- **A control-plane outage no longer tears down a working tunnel.** The app used
|
||||
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, which keeps running regardless.
|
||||
|
||||
### Fixed
|
||||
- **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 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
|
||||
|
||||
### Fixed
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
# Privacy Policy
|
||||
|
||||
**Last updated:** September 2026
|
||||
**Last updated:** October 2026
|
||||
|
||||
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:
|
||||
|
||||
- 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).
|
||||
|
||||
## What our server sees
|
||||
@@ -27,14 +27,14 @@ Support ads (the "Support us" button) extend an active session by 30 minutes and
|
||||
|
||||
## 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
|
||||
|
||||
- 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 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
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import android.os.VibratorManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
@@ -61,14 +62,32 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "updateNotificationText") {
|
||||
val text = call.argument<String>("text") ?: ""
|
||||
val title = call.argument<String>("title") ?: "Revolt VPN"
|
||||
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
||||
updateVpnNotification(title, text, action)
|
||||
result.success(null)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
when (call.method) {
|
||||
"updateNotificationText" -> {
|
||||
val text = call.argument<String>("text") ?: ""
|
||||
val title = call.argument<String>("title") ?: "Revolt VPN"
|
||||
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
||||
updateVpnNotification(title, text, action)
|
||||
result.success(null)
|
||||
}
|
||||
// Session ends only Dart sees; the service posts the rest.
|
||||
"showDisconnectedAlert" -> {
|
||||
RevoltAlerts.postDisconnected(this)
|
||||
result.success(null)
|
||||
}
|
||||
// The countdown re-post is ongoing and non-dismissible, so
|
||||
// whoever posted it last has to take it down. A tick that
|
||||
// lands after the service cancelled its own foreground
|
||||
// notification otherwise strands a frozen countdown that
|
||||
// claims a protected session forever. Harmless while the
|
||||
// service is still in the foreground - the platform keeps
|
||||
// its notification regardless.
|
||||
"cancelNotification" -> {
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
|
||||
.cancel(NOTIFICATION_ID)
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,6 +5,41 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.dart';
|
||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||
|
||||
// The one line worth showing right now, and whether it is a problem.
|
||||
class _StatusDetail {
|
||||
const _StatusDetail(this.text, {this.isProblem = false});
|
||||
|
||||
final String text;
|
||||
final bool isProblem;
|
||||
}
|
||||
|
||||
_StatusDetail? _detail(VpnConnection vpn, SessionTimer timer) {
|
||||
final error = vpn.errorMessage;
|
||||
if (error != null && error.isNotEmpty) {
|
||||
return _StatusDetail(error.replaceAll('\n', ' '), isProblem: true);
|
||||
}
|
||||
|
||||
if (vpn.status == VpnStatus.connecting ||
|
||||
vpn.status == VpnStatus.disconnecting) {
|
||||
return _StatusDetail(vpn.statusMessage);
|
||||
}
|
||||
|
||||
// Control plane only - the tunnel still carries traffic.
|
||||
if (vpn.status == VpnStatus.connected && timer.serverUnreachable) {
|
||||
return const _StatusDetail(
|
||||
'Can\'t reach the server — still connected',
|
||||
isProblem: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (vpn.status != VpnStatus.connected) {
|
||||
final ended = timer.lastEndMessage;
|
||||
if (ended != null) return _StatusDetail(ended, isProblem: true);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class StatusBar extends StatelessWidget {
|
||||
const StatusBar({super.key});
|
||||
|
||||
@@ -14,15 +49,16 @@ class StatusBar extends StatelessWidget {
|
||||
builder: (context, vpn, timer, _) {
|
||||
final isConnected = vpn.status == VpnStatus.connected;
|
||||
final isConnecting = vpn.status == VpnStatus.connecting;
|
||||
final serverHealthy =
|
||||
vpn.serverReachable || (isConnected && timer.hasSyncedOnce);
|
||||
final sessionOrTunnelHealthy = timer.hasSyncedOnce || vpn.serverReachable;
|
||||
// Rule 60: reachability is known only from a session sync that
|
||||
// succeeded, never from a probe. While disconnected the app knows
|
||||
// nothing about the server, so it claims nothing either way.
|
||||
final serverHealthy = isConnected && timer.hasSyncedOnce;
|
||||
|
||||
final statusLabel = isConnected
|
||||
? (sessionOrTunnelHealthy ? 'Online' : 'Syncing…')
|
||||
: (isConnecting
|
||||
? 'Connecting…'
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||
? (timer.hasSyncedOnce ? 'Online' : 'Syncing…')
|
||||
: (isConnecting ? 'Connecting…' : 'Ready');
|
||||
|
||||
final detail = _detail(vpn, timer);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
@@ -96,6 +132,21 @@ class StatusBar extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Errors and session-end reasons used to render nowhere.
|
||||
if (detail != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
detail.text,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: detail.isProblem
|
||||
? AppColors.red
|
||||
: AppColors.textDim,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
+14
-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,16 @@ 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 put in the SSV custom_data, null if unrewarded. connect()
|
||||
// must reuse it: minting a second nonce there rejects the ad's own session.
|
||||
Future<String?> showAd(String adType) async {
|
||||
if (!adsEnabled) {
|
||||
// Main connect fires its own bypass in fetchConfigDirectly (rule 87).
|
||||
if (adType != 'support' || !kDebugMode) return null;
|
||||
|
||||
// Needs ADMOB_BYPASS=True server-side.
|
||||
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 +117,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 +179,7 @@ class AdManager extends ChangeNotifier {
|
||||
},
|
||||
);
|
||||
|
||||
return rewardCompleter.future;
|
||||
return await rewardCompleter.future ? nonce : null;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -76,18 +76,83 @@ class HivemindService {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<http.Response> controlPost(
|
||||
Uri uri,
|
||||
Map<String, dynamic> body, {
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final client = http.Client();
|
||||
final request = http.Request('POST', uri)
|
||||
..headers['User-Agent'] = _ua
|
||||
..headers['Content-Type'] = 'application/json'
|
||||
..body = jsonEncode(body)
|
||||
..followRedirects = false;
|
||||
|
||||
Future<http.Response> send() async {
|
||||
final streamed = await client.send(request);
|
||||
final declaredLength = streamed.contentLength;
|
||||
if (declaredLength != null && declaredLength > _maxControlResponseBytes) {
|
||||
throw const FormatException('Control response is too large.');
|
||||
}
|
||||
|
||||
final bytes = <int>[];
|
||||
await for (final chunk in streamed.stream) {
|
||||
if (bytes.length + chunk.length > _maxControlResponseBytes) {
|
||||
throw const FormatException('Control response is too large.');
|
||||
}
|
||||
bytes.addAll(chunk);
|
||||
}
|
||||
|
||||
return http.Response.bytes(bytes, streamed.statusCode,
|
||||
headers: streamed.headers, request: request);
|
||||
}
|
||||
|
||||
try {
|
||||
return await send().timeout(
|
||||
timeout,
|
||||
onTimeout: () {
|
||||
client.close();
|
||||
throw TimeoutException('Control request timed out.', timeout);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Hands the session back so the server can drop the VLESS client instead of
|
||||
// leaving it provisioned until the window runs out.
|
||||
//
|
||||
// Only the user-initiated disconnect calls this. An expiry, a hard cap or a
|
||||
// server-ended session is already gone server-side, and /session/stop takes
|
||||
// nothing but a device_id as proof (hivemind/api.py notes this), so a
|
||||
// transient client error must never be allowed to destroy a live session.
|
||||
// Best effort throughout: the session expires on its own regardless.
|
||||
static Future<void> releaseSession() async {
|
||||
try {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
await controlPost(
|
||||
_publicUrl('/session/stop'),
|
||||
{'device_id': deviceId},
|
||||
timeout: const Duration(seconds: 3),
|
||||
);
|
||||
} catch (_) {
|
||||
// Nothing to recover: the server expires the session anyway.
|
||||
}
|
||||
}
|
||||
|
||||
static void cancel() {
|
||||
_currentCallId++;
|
||||
_expectedNonce = null;
|
||||
}
|
||||
|
||||
static void setExpectedNonce(String nonce) {
|
||||
_expectedNonce = nonce;
|
||||
}
|
||||
|
||||
// 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,
|
||||
bool skipAdBypass = false,
|
||||
String? adNonce,
|
||||
}) 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 +161,15 @@ class HivemindService {
|
||||
final deviceId = await CryptoService.getDeviceId();
|
||||
_throwIfCancelled(callId);
|
||||
|
||||
final nonce = _newNonce();
|
||||
final nonce = adNonce ?? newNonce();
|
||||
_expectedNonce = nonce;
|
||||
|
||||
if (!skipAdBypass) {
|
||||
if (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 +187,9 @@ class HivemindService {
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
await Future.delayed(_pollBackoff[attempt - 1]);
|
||||
await Future.delayed(
|
||||
_pollBackoff[min(attempt - 1, _pollBackoff.length - 1)],
|
||||
);
|
||||
_throwIfCancelled(callId);
|
||||
}
|
||||
}
|
||||
@@ -130,19 +197,7 @@ class HivemindService {
|
||||
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 high = random.nextInt(0x7FFFFFFF);
|
||||
final low = random.nextInt(0x7FFFFFFF);
|
||||
@@ -175,11 +230,8 @@ class HivemindService {
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
final serverNonce = decoded['nonce'];
|
||||
if (_expectedNonce != nonce ||
|
||||
serverNonce is! String ||
|
||||
serverNonce != nonce) {
|
||||
return null;
|
||||
}
|
||||
if (_expectedNonce != nonce) return null;
|
||||
if (serverNonce is! String || serverNonce != nonce) return null;
|
||||
|
||||
if (decoded['active'] != true || decoded['vless_uuid'] == null) {
|
||||
return null;
|
||||
|
||||
@@ -32,6 +32,30 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Same id as the service's own alert, so a double post replaces.
|
||||
static Future<void> showDisconnectedAlert() async {
|
||||
try {
|
||||
await _channel.invokeMethod<void>('showDisconnectedAlert');
|
||||
} catch (_) {
|
||||
// Informational only.
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes down the countdown notification.
|
||||
///
|
||||
/// [updateTimer] re-posts an ongoing, non-dismissible notification, so a tick
|
||||
/// that lands after the VPN service has cancelled its own foreground copy
|
||||
/// leaves a frozen countdown on screen that nobody owns. Call this once the
|
||||
/// tunnel is actually down.
|
||||
static Future<void> cancelTimer() async {
|
||||
_lastText = null;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('cancelNotification');
|
||||
} catch (_) {
|
||||
// Cosmetic.
|
||||
}
|
||||
}
|
||||
|
||||
/// Forces the next connect to re-post even if the countdown resumes on the
|
||||
/// same value.
|
||||
static void reset() => _lastText = null;
|
||||
|
||||
@@ -36,14 +36,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
||||
|
||||
static const int _maxConsecutiveFailures = 3;
|
||||
static const int _maxOfflineSeconds = 120;
|
||||
int _offlineSeconds = 0;
|
||||
|
||||
static const int _pollIntervalSeconds = 60;
|
||||
static const int _backgroundPollIntervalSeconds = 60;
|
||||
|
||||
int _lastUsedBytes = 0;
|
||||
Stopwatch? _sinceLastSuccessfulSync;
|
||||
// Wall clock, because the Stopwatch is CLOCK_MONOTONIC and stops across
|
||||
// device suspend - exactly when the RTC_WAKEUP expiry alarm fires.
|
||||
DateTime? _deadlineAt;
|
||||
double _currentSpeedKBps = 0.0;
|
||||
|
||||
SessionTimer({required this.vpnConnection}) {
|
||||
@@ -67,6 +68,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
int get usedBytes => _usedBytes;
|
||||
double get currentSpeedKBps => _currentSpeedKBps;
|
||||
|
||||
// Display state only - an unreachable API never tears down a live tunnel.
|
||||
bool get serverUnreachable => _consecutiveFailures >= _maxConsecutiveFailures;
|
||||
|
||||
// Why the last session ended, if the user did not end it. Cleared on connect.
|
||||
String? _lastEndMessage;
|
||||
String? get lastEndMessage => _lastEndMessage;
|
||||
|
||||
String get formatted {
|
||||
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
||||
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
||||
@@ -124,12 +132,29 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
if (!hadActiveSession || _isDisconnecting) return;
|
||||
|
||||
if (vpnConnection.status == VpnStatus.error) {
|
||||
unawaited(_doDisconnect('VPN entered error state'));
|
||||
unawaited(_doDisconnect(
|
||||
'VPN entered error state',
|
||||
endedMessage: 'The tunnel stopped. You are not protected.',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (vpnConnection.status == VpnStatus.disconnected) {
|
||||
unawaited(_doDisconnect('VPN tunnel dropped'));
|
||||
// The native deadline beats the 1 s tick, so expiry lands here, not in
|
||||
// _tick. postAlert is false: the service already posted, and a
|
||||
// notification-Disconnect tap is the user asking.
|
||||
final deadline = _deadlineAt;
|
||||
final expired = _hasSyncedOnce &&
|
||||
deadline != null &&
|
||||
DateTime.now().isAfter(
|
||||
deadline.subtract(const Duration(seconds: 5)),
|
||||
);
|
||||
unawaited(_doDisconnect(
|
||||
'VPN tunnel dropped',
|
||||
endedMessage:
|
||||
expired ? 'Your session ended. You are not protected.' : null,
|
||||
postAlert: false,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,15 +166,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_lastUsedBytes = 0;
|
||||
_sinceLastSuccessfulSync?.stop();
|
||||
_sinceLastSuccessfulSync = null;
|
||||
_deadlineAt = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
_tickCount = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_isDisconnecting = false;
|
||||
_appBackgrounded = false;
|
||||
_lastEndMessage = null;
|
||||
NotificationService.reset();
|
||||
|
||||
// Every session is new, so the once-per-session top-up resets with it.
|
||||
_supportStateEpoch++;
|
||||
_supportRewardClaimed = false;
|
||||
_supportRewardStateLoaded = true;
|
||||
@@ -169,16 +196,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_reconcileElapsedTime();
|
||||
}
|
||||
|
||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
||||
_offlineSeconds++;
|
||||
if (_offlineSeconds >= _maxOfflineSeconds) {
|
||||
unawaited(_doDisconnect('Server unreachable'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
||||
unawaited(_doDisconnect('Session expired'));
|
||||
unawaited(_doDisconnect(
|
||||
'Session expired',
|
||||
endedMessage: 'Your session ended. You are not protected.',
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -216,15 +238,27 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
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.
|
||||
unawaited(HivemindService.releaseSession());
|
||||
await _doDisconnect(reason);
|
||||
}
|
||||
|
||||
Future<void> _doDisconnect(String reason) async {
|
||||
// endedMessage is what the UI shows; postAlert raises the notification.
|
||||
// A user-requested disconnect passes neither.
|
||||
Future<void> _doDisconnect(
|
||||
String reason, {
|
||||
String? endedMessage,
|
||||
bool postAlert = true,
|
||||
}) async {
|
||||
if (_isDisconnecting) return;
|
||||
_isDisconnecting = true;
|
||||
_sessionEpoch++;
|
||||
debugPrint('[Timer] Disconnecting: $reason');
|
||||
|
||||
_lastEndMessage = endedMessage;
|
||||
_deadlineAt = null;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
@@ -237,6 +271,13 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
notifyListeners();
|
||||
|
||||
await vpnConnection.disconnect();
|
||||
// After the service is down, so the cancel is not swallowed by a still
|
||||
// foreground notification. Without it a revoke leaves the countdown on
|
||||
// screen, frozen, claiming a session that no longer protects anything.
|
||||
await NotificationService.cancelTimer();
|
||||
if (endedMessage != null && postAlert) {
|
||||
unawaited(NotificationService.showDisconnectedAlert());
|
||||
}
|
||||
|
||||
_isDisconnecting = false;
|
||||
notifyListeners();
|
||||
@@ -282,7 +323,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return;
|
||||
}
|
||||
if (!activeValue) {
|
||||
await _doDisconnect('Server ended session');
|
||||
await _doDisconnect(
|
||||
'Server ended session',
|
||||
endedMessage:
|
||||
'The server ended your session. You are not protected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -298,6 +343,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
final elapsedMs = _sinceLastSuccessfulSync?.elapsedMilliseconds;
|
||||
_remainingAtLastSync = expiresValue.toInt();
|
||||
_remainingSeconds = _remainingAtLastSync;
|
||||
_deadlineAt = DateTime.now().add(
|
||||
Duration(seconds: _remainingAtLastSync),
|
||||
);
|
||||
_usedBytes = _readNonNegativeInt(data['used_bytes'], _usedBytes);
|
||||
|
||||
final deltaBytes = _usedBytes - _lastUsedBytes;
|
||||
@@ -313,7 +361,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_sinceLastSuccessfulSync = Stopwatch()..start();
|
||||
|
||||
if (data['cap_exhausted'] == true) {
|
||||
await _doDisconnect('Data cap reached');
|
||||
await _doDisconnect(
|
||||
'Data cap reached',
|
||||
endedMessage: 'Data limit reached. You are not protected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -322,12 +373,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync);
|
||||
} catch (e) {
|
||||
debugPrint('[Timer] Failed to arm native session deadline: $e');
|
||||
await _doDisconnect('Native session deadline unavailable');
|
||||
await _doDisconnect(
|
||||
'Native session deadline unavailable',
|
||||
endedMessage:
|
||||
'Could not arm the session timer. You are not protected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_hasSyncedOnce = true;
|
||||
notifyListeners();
|
||||
} else {
|
||||
@@ -364,6 +418,16 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
final elapsed = clock.elapsed.inSeconds;
|
||||
_remainingSeconds =
|
||||
(_remainingAtLastSync - elapsed).clamp(0, 1 << 31).toInt();
|
||||
|
||||
// The Stopwatch over-reports after device suspend. Take the smaller of
|
||||
// the two, never the wall clock alone - a backward clock must not extend.
|
||||
final deadline = _deadlineAt;
|
||||
if (deadline != null) {
|
||||
final byWallClock = deadline.difference(DateTime.now()).inSeconds;
|
||||
if (byWallClock < _remainingSeconds) {
|
||||
_remainingSeconds = byWallClock.clamp(0, 1 << 31).toInt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -38,9 +38,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
bool _adoptedRunningRuntime = false;
|
||||
bool get adoptedRunningRuntime => _adoptedRunningRuntime;
|
||||
|
||||
bool _serverReachable = false;
|
||||
bool get serverReachable => _serverReachable;
|
||||
|
||||
ConnectionMode _activeMode = ConnectionMode.tun;
|
||||
ConnectionMode get activeMode => _activeMode;
|
||||
|
||||
@@ -73,7 +70,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
Timer? _healthTimer;
|
||||
StreamSubscription<NetworkSnapshot>? _networkSubscription;
|
||||
|
||||
SecureSocksSession? _lastSecureSocks;
|
||||
@@ -142,11 +138,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
},
|
||||
);
|
||||
|
||||
unawaited(_checkHealth());
|
||||
_healthTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
unawaited(_checkHealth());
|
||||
});
|
||||
|
||||
try {
|
||||
final coreVersion = await _vless.getCoreVersion();
|
||||
if (!_disposed) {
|
||||
@@ -200,7 +191,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> connect({bool skipAdBypass = false}) async {
|
||||
Future<bool> connect({String? adNonce}) async {
|
||||
if (_disposed ||
|
||||
_userDisconnecting ||
|
||||
_status == VpnStatus.connected ||
|
||||
@@ -224,6 +215,17 @@ class VpnConnection extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Proxy mode skips requestPermission below (VPN consent it never needs),
|
||||
// so it has to ask for POST_NOTIFICATIONS on its own.
|
||||
if (!kIsWeb && _activeMode == ConnectionMode.proxy) {
|
||||
try {
|
||||
await _nativeControl.invokeMethod<void>('requestNotificationPermission');
|
||||
} catch (e) {
|
||||
debugPrint('[VPN] Notification permission request failed: $e');
|
||||
}
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
}
|
||||
|
||||
if (!kIsWeb && _activeMode == ConnectionMode.tun) {
|
||||
final ok = await _vless.requestPermission();
|
||||
if (!_isCurrentConnect(connectEpoch)) return false;
|
||||
@@ -254,7 +256,7 @@ class VpnConnection extends ChangeNotifier {
|
||||
String realUrl;
|
||||
try {
|
||||
realUrl = await HivemindService.fetchConfigDirectly(
|
||||
skipAdBypass: skipAdBypass,
|
||||
adNonce: adNonce,
|
||||
onAttempt: (attempt, total) {
|
||||
if (!_isCurrentConnect(connectEpoch)) return;
|
||||
_setStatus(
|
||||
@@ -461,14 +463,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
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
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
@@ -476,7 +470,6 @@ class VpnConnection extends ChangeNotifier {
|
||||
_suppressNativeConnect = true;
|
||||
_userDisconnecting = true;
|
||||
HivemindService.cancel();
|
||||
_healthTimer?.cancel();
|
||||
_networkSubscription?.cancel();
|
||||
// Provider/UI disposal is not a user disconnect command. Keeping teardown
|
||||
// in disconnect() prevents lifecycle churn from silently dropping the VPN.
|
||||
|
||||
@@ -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,25 @@ 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 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()) {
|
||||
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();
|
||||
}
|
||||
|
||||
+22
@@ -240,6 +240,28 @@ class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.Activity
|
||||
}
|
||||
}
|
||||
|
||||
// POST_NOTIFICATIONS without VpnService.prepare, for proxy mode.
|
||||
"requestNotificationPermission" -> {
|
||||
val currentActivity = activity
|
||||
if (currentActivity == null) {
|
||||
result.error("NO_ACTIVITY", "Activity is not attached", null)
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ActivityCompat.checkSelfPermission(
|
||||
currentActivity,
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
) != PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
ActivityCompat.requestPermissions(
|
||||
currentActivity,
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
REQUEST_CODE_POST_NOTIFICATIONS,
|
||||
)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"requestPermission" -> {
|
||||
val currentActivity = activity
|
||||
if (currentActivity == null) {
|
||||
|
||||
+63
-5
@@ -11,6 +11,8 @@ import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.CountDownTimer
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.app.NotificationCompat
|
||||
@@ -37,6 +39,13 @@ object XrayCoreManager {
|
||||
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val TAG = "XrayCoreManager"
|
||||
|
||||
// The core's output is kept only for its first seconds, and only to explain
|
||||
// a non-zero exit. A core that has been up longer than this may be carrying
|
||||
// traffic, and its log can name destinations, which must never reach the
|
||||
// device log (rule 95) - so past the window the buffer is dropped.
|
||||
private const val STARTUP_LOG_WINDOW_MS = 10_000L
|
||||
private const val STARTUP_LOG_MAX_LINES = 40
|
||||
private var xrayProcess: Process? = null
|
||||
private var countDownTimer: CountDownTimer? = null
|
||||
private var seconds = 0
|
||||
@@ -347,12 +356,29 @@ object XrayCoreManager {
|
||||
// Monitor process in a separate thread to detect crash
|
||||
Thread {
|
||||
try {
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val startupLog = ArrayDeque<String>()
|
||||
process.inputStream.bufferedReader().use { reader ->
|
||||
reader.forEachLine { _ -> }
|
||||
reader.forEachLine { line ->
|
||||
if (System.currentTimeMillis() - startedAt <= STARTUP_LOG_WINDOW_MS) {
|
||||
if (startupLog.size >= STARTUP_LOG_MAX_LINES) startupLog.removeFirst()
|
||||
startupLog.addLast(line)
|
||||
} else if (startupLog.isNotEmpty()) {
|
||||
startupLog.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val exitCode = process.waitFor()
|
||||
Log.e(TAG, "Xray process exited with code $exitCode")
|
||||
if (exitCode != 0 && startupLog.isNotEmpty()) {
|
||||
Log.e(
|
||||
TAG,
|
||||
"Xray process exited with code $exitCode; startup output:\n" +
|
||||
startupLog.joinToString("\n")
|
||||
)
|
||||
} else {
|
||||
Log.e(TAG, "Xray process exited with code $exitCode")
|
||||
}
|
||||
if (xrayProcess === process &&
|
||||
AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN == config.RUNTIME_TOKEN &&
|
||||
(AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTED ||
|
||||
@@ -383,6 +409,18 @@ object XrayCoreManager {
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to start Xray process", e)
|
||||
// The process may already be running: pb.start() succeeds before
|
||||
// anything below it can throw. Leaving it alive leaked one live
|
||||
// core per recovery attempt. Clear the field BEFORE destroying so
|
||||
// the monitor thread's `xrayProcess === process` guard fails and
|
||||
// recovery is not re-entered for a core we are killing here.
|
||||
val orphan = xrayProcess
|
||||
xrayProcess = null
|
||||
try {
|
||||
orphan?.destroy()
|
||||
} catch (destroyError: Exception) {
|
||||
Log.w(TAG, "Failed to destroy partially started Xray process", destroyError)
|
||||
}
|
||||
deleteEphemeralConfigOnFailure(configFilesDir)
|
||||
return false
|
||||
}
|
||||
@@ -429,7 +467,25 @@ object XrayCoreManager {
|
||||
AppConfigs.V2RAY_STATE == AppConfigs.V2RAY_STATES.V2RAY_CONNECTING
|
||||
}
|
||||
|
||||
// CountDownTimer builds a Handler, so it only constructs on a looper thread.
|
||||
// Core recovery runs on a bare Thread (XrayVPNService.handleXrayCoreExit),
|
||||
// where this threw "Can't create handler inside thread ... that has not
|
||||
// called Looper.prepare()" and took the whole restart down with it.
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
|
||||
}
|
||||
|
||||
private fun startTimer(context: Context) {
|
||||
// Captured now: a stop that lands while the post is queued must not
|
||||
// leave a timer ticking for a generation that is already gone.
|
||||
val token = AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN
|
||||
runOnMain { startTimerOnMain(context, token) }
|
||||
}
|
||||
|
||||
private fun startTimerOnMain(context: Context, token: String?) {
|
||||
if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return
|
||||
countDownTimer?.cancel()
|
||||
seconds = 0
|
||||
countDownTimer = object : CountDownTimer(Long.MAX_VALUE, 1000) {
|
||||
@@ -454,9 +510,11 @@ object XrayCoreManager {
|
||||
}
|
||||
|
||||
private fun stopTimer() {
|
||||
countDownTimer?.cancel()
|
||||
countDownTimer = null
|
||||
seconds = 0
|
||||
runOnMain {
|
||||
countDownTimer?.cancel()
|
||||
countDownTimer = null
|
||||
seconds = 0
|
||||
}
|
||||
}
|
||||
|
||||
fun sendStatusBroadcast(context: Context, config: XrayConfig) {
|
||||
|
||||
+144
-3
@@ -16,6 +16,7 @@ import android.util.Log
|
||||
import com.github.tfox.flutter_vless.xray.core.XrayCoreManager
|
||||
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
@@ -44,6 +45,14 @@ class XrayVPNService : VpnService() {
|
||||
private var sessionDeadlineToken: String? = null
|
||||
private val sessionDeadlineRunnable = Runnable { enforceSessionDeadline() }
|
||||
|
||||
// The warning's own backstop, mirroring the deadline's. The alarm alone is
|
||||
// not enough: it is inexact on purpose (no SCHEDULE_EXACT_ALARM), and with
|
||||
// the app closed on a dozing device it was measured never arriving at all,
|
||||
// while the deadline's Handler fired within a second of its target.
|
||||
private val sessionWarningRunnable = Runnable {
|
||||
handleSessionWarningAlarm(sessionDeadlineToken.orEmpty())
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
@@ -66,6 +75,13 @@ class XrayVPNService : VpnService() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Must stay above the command dispatch: an intent with no COMMAND
|
||||
// falls through to the final else and stops the tunnel.
|
||||
if (intent.getBooleanExtra(EXTRA_SESSION_WARNING, false)) {
|
||||
handleSessionWarningAlarm(intent.getStringExtra("RUNTIME_TOKEN").orEmpty())
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val command = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
|
||||
} else {
|
||||
@@ -82,7 +98,7 @@ class XrayVPNService : VpnService() {
|
||||
Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation")
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
stopAll(requestedToken)
|
||||
stopAll(requestedToken, userRequested = true)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
@@ -121,6 +137,7 @@ class XrayVPNService : VpnService() {
|
||||
XrayCoreManager.stopCore(this)
|
||||
}
|
||||
cleanup(clearDeadline = true)
|
||||
RevoltAlerts.cancelAll(this)
|
||||
shuttingDownIntentionally = false
|
||||
currentConfig = config
|
||||
|
||||
@@ -418,14 +435,21 @@ class XrayVPNService : VpnService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopAll(confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty()) {
|
||||
// Anything that stops a live tunnel unasked must say so. isRunning is read
|
||||
// before cleanup() clears it, which keeps a failed connect silent.
|
||||
private fun stopAll(
|
||||
confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty(),
|
||||
userRequested: Boolean = false,
|
||||
) {
|
||||
if (shuttingDownIntentionally) return
|
||||
val alertUser = isRunning && !userRequested
|
||||
shuttingDownIntentionally = true
|
||||
cleanup(clearDeadline = true)
|
||||
currentConfig = null
|
||||
XrayCoreManager.stopCore(this, confirmationToken)
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
if (alertUser) RevoltAlerts.postDisconnected(this)
|
||||
}
|
||||
|
||||
private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) {
|
||||
@@ -451,17 +475,91 @@ class XrayVPNService : VpnService() {
|
||||
return
|
||||
}
|
||||
|
||||
armExpiryWarning(config.RUNTIME_TOKEN, epochDeadline)
|
||||
|
||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||
if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
|
||||
else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
|
||||
}
|
||||
|
||||
// Advisory, unlike the deadline itself: nothing here fails closed. Dart
|
||||
// re-pushes the deadline every sync, so this runs about once a minute.
|
||||
private fun armExpiryWarning(runtimeToken: String, expiresAtEpochMs: Long) {
|
||||
if (runtimeToken.isEmpty()) return
|
||||
|
||||
val warnAt = expiresAtEpochMs - RevoltAlerts.WARNING_LEAD_MS
|
||||
// Inside the window already (or the short bootstrap deadline). Cancel
|
||||
// nothing here: with no dedupe on the re-push, cancelling on this
|
||||
// branch deletes the armed warning and never re-arms it.
|
||||
if (warnAt <= System.currentTimeMillis() + WARNING_REARM_GRACE_MS) return
|
||||
|
||||
cancelSessionWarningAlarm()
|
||||
// A warning on screen now belongs to an older deadline (top-up).
|
||||
RevoltAlerts.cancelExpiryWarning(this)
|
||||
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val pendingIntent = sessionWarningPendingIntent(runtimeToken)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// Inexact on purpose - keeps this off SCHEDULE_EXACT_ALARM.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
warnAt,
|
||||
pendingIntent,
|
||||
)
|
||||
} else {
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, warnAt, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to schedule session-expiry warning", e)
|
||||
}
|
||||
|
||||
// postDelayed runs on uptimeMillis, so a truly suspended device still
|
||||
// fires this late - handleSessionWarningAlarm then rejects it on the
|
||||
// window check rather than claiming "5 minutes" with seconds left.
|
||||
// Whenever the process is merely backgrounded, which is the common
|
||||
// case, this is what actually delivers the warning.
|
||||
val warnDelay = warnAt - System.currentTimeMillis()
|
||||
deadlineHandler.removeCallbacks(sessionWarningRunnable)
|
||||
if (warnDelay > 0L) {
|
||||
deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionWarningAlarm(alarmToken: String) {
|
||||
val config = currentConfig
|
||||
if (config == null) {
|
||||
// Woke a process with no live runtime; nothing to warn about.
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
if (alarmToken.isEmpty() || alarmToken != config.RUNTIME_TOKEN) {
|
||||
Log.w(TAG, "Ignoring session warning for stale runtime generation")
|
||||
return
|
||||
}
|
||||
|
||||
val deadline = sessionDeadlineElapsed ?: return
|
||||
val remaining = deadline - SystemClock.elapsedRealtime()
|
||||
if (remaining < WARNING_FLOOR_MS ||
|
||||
remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS
|
||||
) {
|
||||
// Too late, or the deadline moved. Saying "5 minutes" with
|
||||
// seconds left is worse than silence.
|
||||
Log.w(TAG, "Ignoring session warning outside the warning window")
|
||||
return
|
||||
}
|
||||
|
||||
RevoltAlerts.postExpiryWarning(this)
|
||||
}
|
||||
|
||||
private fun clearSessionDeadline() {
|
||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||
sessionDeadlineElapsed = null
|
||||
sessionDeadlineToken = null
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
cancelSessionWarningAlarm()
|
||||
RevoltAlerts.cancelExpiryWarning(this)
|
||||
}
|
||||
|
||||
private fun enforceSessionDeadline() {
|
||||
@@ -561,8 +659,12 @@ class XrayVPNService : VpnService() {
|
||||
} else {
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
cancelSessionWarningAlarm()
|
||||
RevoltAlerts.cancelExpiryWarning(this)
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
// This arm does not go through stopAll, so it posts for itself.
|
||||
RevoltAlerts.postDisconnected(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,6 +709,37 @@ class XrayVPNService : VpnService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSessionWarningAlarm() {
|
||||
deadlineHandler.removeCallbacks(sessionWarningRunnable)
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
// Look it up rather than build it: FLAG_UPDATE_CURRENT would
|
||||
// rewrite the live alarm's RUNTIME_TOKEN to "" before cancelling.
|
||||
existingSessionWarningPendingIntent()?.let { alarmManager.cancel(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to cancel session-warning alarm", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun existingSessionWarningPendingIntent(): PendingIntent? {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
.putExtra(EXTRA_SESSION_WARNING, true)
|
||||
val flags = PendingIntent.FLAG_NO_CREATE or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags)
|
||||
}
|
||||
|
||||
// Identity is component + request code, not extras, so the warning needs
|
||||
// its own code or it clobbers the expiry alarm.
|
||||
private fun sessionWarningPendingIntent(runtimeToken: String): PendingIntent {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
.putExtra(EXTRA_SESSION_WARNING, true)
|
||||
.putExtra("RUNTIME_TOKEN", runtimeToken)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
return PendingIntent.getService(this, SESSION_WARNING_REQUEST_CODE, intent, flags)
|
||||
}
|
||||
|
||||
private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
.putExtra(EXTRA_SESSION_EXPIRED, true)
|
||||
@@ -628,7 +761,7 @@ class XrayVPNService : VpnService() {
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
stopAll()
|
||||
stopAll(userRequested = true)
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
@@ -681,6 +814,14 @@ class XrayVPNService : VpnService() {
|
||||
private const val PREF_RUNTIME_TOKEN = "runtime_token"
|
||||
private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
|
||||
private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED"
|
||||
private const val EXTRA_SESSION_WARNING = "SESSION_WARNING"
|
||||
private const val SESSION_EXPIRY_REQUEST_CODE = 1001
|
||||
private const val SESSION_WARNING_REQUEST_CODE = 1002
|
||||
// Drift allowed before a warning is treated as belonging elsewhere.
|
||||
private const val WARNING_SLACK_MS = 60_000L
|
||||
// Don't re-arm for a deadline that moved by round-trip jitter alone.
|
||||
private const val WARNING_REARM_GRACE_MS = 15_000L
|
||||
// Below this the warning has lost its point.
|
||||
private const val WARNING_FLOOR_MS = 60_000L
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.github.tfox.flutter_vless.xray.utils
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
// local_packages/.../xray/utils/RevoltAlerts.kt
|
||||
//
|
||||
// The two session alerts. Both XrayVPNService (:RunSoLibXrayDaemon) and
|
||||
// MainActivity post them, sharing ids so a second post replaces. Separate from
|
||||
// the foreground-service notification (id 1, IMPORTANCE_LOW): these must be
|
||||
// noticed, and must be dismissible.
|
||||
object RevoltAlerts {
|
||||
|
||||
const val CHANNEL_ID = "REVOLT_VPN_ALERTS"
|
||||
const val WARNING_NOTIFICATION_ID = 2
|
||||
const val DISCONNECTED_NOTIFICATION_ID = 3
|
||||
|
||||
const val WARNING_LEAD_MS = 5 * 60 * 1000L
|
||||
|
||||
private const val TAG = "RevoltAlerts"
|
||||
private const val LAUNCH_REQUEST_CODE = 2001
|
||||
|
||||
// Neither line names a number or a mechanism, because this notification
|
||||
// cannot honour either. The caller accepts it anywhere from WARNING_FLOOR_MS
|
||||
// to WARNING_LEAD_MS + WARNING_SLACK_MS remaining, so "5 minutes" can be
|
||||
// off by four of them; and "watch an ad" is a dead end in a release build,
|
||||
// where the support bypass is still kDebugMode-gated (rule 87). Put the
|
||||
// number back once the warning is punctual, and the ad back once the
|
||||
// support button works without a debug build.
|
||||
fun postExpiryWarning(context: Context) {
|
||||
post(
|
||||
context,
|
||||
WARNING_NOTIFICATION_ID,
|
||||
"Revolt VPN disconnects soon",
|
||||
"Open the app to extend your session.",
|
||||
)
|
||||
}
|
||||
|
||||
fun postDisconnected(context: Context) {
|
||||
post(
|
||||
context,
|
||||
DISCONNECTED_NOTIFICATION_ID,
|
||||
"Revolt VPN disconnected",
|
||||
"Your traffic is no longer protected. Tap to reconnect.",
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelExpiryWarning(context: Context) = cancel(context, WARNING_NOTIFICATION_ID)
|
||||
|
||||
fun cancelDisconnected(context: Context) = cancel(context, DISCONNECTED_NOTIFICATION_ID)
|
||||
|
||||
fun cancelAll(context: Context) {
|
||||
cancelExpiryWarning(context)
|
||||
cancelDisconnected(context)
|
||||
}
|
||||
|
||||
private fun cancel(context: Context, id: Int) {
|
||||
try {
|
||||
manager(context)?.cancel(id)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to cancel alert $id", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(context: Context, id: Int, title: String, text: String) {
|
||||
if (!canPost(context)) {
|
||||
Log.w(TAG, "POST_NOTIFICATIONS not granted; dropping alert $id")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val manager = manager(context) ?: return
|
||||
ensureChannel(manager)
|
||||
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(context, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(context).setPriority(Notification.PRIORITY_HIGH)
|
||||
}
|
||||
|
||||
val icon = context.resources
|
||||
.getIdentifier("notification_icon", "drawable", context.packageName)
|
||||
.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info
|
||||
|
||||
builder
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setSmallIcon(icon)
|
||||
.setAutoCancel(true)
|
||||
.setOngoing(false)
|
||||
// A replacing post must not sound again.
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
launchIntent(context)?.let { builder.setContentIntent(it) }
|
||||
|
||||
manager.notify(id, builder.build())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to post alert $id", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPost(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun manager(context: Context): NotificationManager? =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
|
||||
// Importance is fixed at creation, so ask for HIGH up front.
|
||||
private fun ensureChannel(manager: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session alerts",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
channel.description = "Warns before a session ends and when protection stops."
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun launchIntent(context: Context): PendingIntent? {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?: return null
|
||||
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
return PendingIntent.getActivity(context, LAUNCH_REQUEST_CODE, intent, flags)
|
||||
}
|
||||
}
|
||||
+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.6+32
|
||||
version: 3.3.8+34
|
||||
|
||||
environment:
|
||||
sdk: ">=3.0.0 <4.0.0"
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
// Source assertions, in the style of release_verification_regression_test.dart.
|
||||
// The behaviours below were all found broken on a device and fixed on
|
||||
// 2026-10-17 (internal/DEVICE_TEST_2026-10-17.md). None of them can be covered
|
||||
// by a Dart unit test - they live in the vendored Android runtime, and the one
|
||||
// that does not needs a live tunnel - but every one of them regresses silently
|
||||
// and looks like tidying when it does. Pin the shape instead of the behaviour.
|
||||
|
||||
const _runtime = 'local_packages/flutter_vless_android-1.1.5/android/src/main/'
|
||||
'kotlin/com/github/tfox/flutter_vless/xray';
|
||||
|
||||
void main() {
|
||||
test('Xray core recovery survives running off a bare thread', () {
|
||||
final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync();
|
||||
|
||||
// startTimer builds a CountDownTimer, which builds a Handler. Recovery runs
|
||||
// on a plain Thread, so constructing it inline threw Looper.prepare() and
|
||||
// killed every restart attempt.
|
||||
expect(core, contains('Handler(Looper.getMainLooper())'));
|
||||
expect(core, contains('runOnMain'));
|
||||
expect(
|
||||
core,
|
||||
contains('runOnMain { startTimerOnMain(context, token) }'),
|
||||
reason: 'startTimer must not construct the CountDownTimer inline',
|
||||
);
|
||||
|
||||
// The post must not outlive the generation that scheduled it.
|
||||
expect(core, contains('if (AppConfigs.V2RAY_CONFIG?.RUNTIME_TOKEN != token) return'));
|
||||
|
||||
// pb.start() succeeds before the throw, so the catch has to kill the
|
||||
// process or each retry strands another live core.
|
||||
expect(core, contains('val orphan = xrayProcess'));
|
||||
expect(
|
||||
core.indexOf('xrayProcess = null'),
|
||||
lessThan(core.indexOf('orphan?.destroy()')),
|
||||
reason: 'clear the field before destroying, so the monitor thread\'s '
|
||||
'identity guard fails and recovery is not re-entered',
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed Xray core still reports why, without logging destinations', () {
|
||||
final core = File('$_runtime/core/XrayCoreManager.kt').readAsStringSync();
|
||||
|
||||
expect(core, contains('STARTUP_LOG_WINDOW_MS'));
|
||||
expect(core, contains('STARTUP_LOG_MAX_LINES'));
|
||||
expect(
|
||||
core,
|
||||
isNot(contains('reader.forEachLine { _ -> }')),
|
||||
reason: 'discarding the core output leaves exit 23 undiagnosable',
|
||||
);
|
||||
// Rule 95: a core that has been up long enough to carry traffic must not
|
||||
// have its log retained.
|
||||
expect(core, contains('startupLog.clear()'));
|
||||
});
|
||||
|
||||
test('the expiry warning has the same backstop as the deadline', () {
|
||||
final service = File('$_runtime/service/XrayVPNService.kt').readAsStringSync();
|
||||
|
||||
// The alarm alone is inexact on purpose (no SCHEDULE_EXACT_ALARM) and Doze
|
||||
// deferred it past the end of the session it was warning about.
|
||||
expect(service, contains('sessionWarningRunnable'));
|
||||
expect(
|
||||
service,
|
||||
contains('deadlineHandler.postDelayed(sessionWarningRunnable, warnDelay)'),
|
||||
reason: 'without this the warning never arrives when the app is closed',
|
||||
);
|
||||
expect(
|
||||
service,
|
||||
contains('deadlineHandler.removeCallbacks(sessionWarningRunnable)'),
|
||||
reason: 'a cancelled warning must drop its Handler callback too',
|
||||
);
|
||||
expect(service, isNot(contains('SCHEDULE_EXACT_ALARM"')));
|
||||
});
|
||||
|
||||
test('the warning promises neither a number of minutes nor an ad', () {
|
||||
final alerts = File('$_runtime/utils/RevoltAlerts.kt').readAsStringSync();
|
||||
|
||||
// It is accepted anywhere from WARNING_FLOOR_MS to LEAD + SLACK remaining,
|
||||
// so a figure can be off by minutes; and the support bypass is still
|
||||
// kDebugMode-gated (rule 87), so an ad is a dead end in release.
|
||||
expect(alerts, isNot(contains('disconnects in 5 minutes')));
|
||||
expect(alerts, isNot(contains('Watch an ad')));
|
||||
expect(alerts, contains('Revolt VPN disconnects soon'));
|
||||
});
|
||||
|
||||
test('the countdown notification is taken down when the tunnel stops', () {
|
||||
final service = File('lib/logic/notification_service.dart').readAsStringSync();
|
||||
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
||||
final activity =
|
||||
File('android/app/src/main/kotlin/com/paladinvpn/app/MainActivity.kt')
|
||||
.readAsStringSync();
|
||||
|
||||
// updateTimer re-posts an ongoing, non-dismissible notification every tick,
|
||||
// so a tick after the service stops strands a frozen countdown.
|
||||
expect(service, contains('cancelTimer'));
|
||||
expect(activity, contains('"cancelNotification"'));
|
||||
expect(timer, contains('await NotificationService.cancelTimer()'));
|
||||
|
||||
// Must run after the service is down, or the platform swallows the cancel.
|
||||
expect(
|
||||
timer.indexOf('await vpnConnection.disconnect();'),
|
||||
lessThan(timer.indexOf('await NotificationService.cancelTimer()')),
|
||||
);
|
||||
});
|
||||
|
||||
test('only a user-requested disconnect hands the session back', () {
|
||||
final timer = File('lib/logic/session_timer.dart').readAsStringSync();
|
||||
final hivemind = File('lib/logic/hivemind_service.dart').readAsStringSync();
|
||||
|
||||
expect(hivemind, contains('releaseSession'));
|
||||
expect(hivemind, contains("_publicUrl('/session/stop')"));
|
||||
|
||||
// /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 doDisconnect = timer.indexOf('Future<void> _doDisconnect(');
|
||||
final release = timer.indexOf('HivemindService.releaseSession()');
|
||||
expect(release, greaterThan(disconnectEntry));
|
||||
expect(
|
||||
release,
|
||||
lessThan(doDisconnect),
|
||||
reason: 'releaseSession belongs in disconnect(), not _doDisconnect',
|
||||
);
|
||||
expect(
|
||||
'HivemindService.releaseSession()'.allMatches(timer).length,
|
||||
1,
|
||||
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'));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user