feat(session): warn before a session ends and say when protection stops
Review findings #1-#3 in internal/APP_REVIEW_2026-10-16.md: every session end dropped the user onto their real IP with no warning, and the app showed no errors at all. - RevoltAlerts (vendored runtime) owns both alerts: a high-importance channel, ids 2 and 3, posted from whichever process is alive - XrayVPNService schedules a 5-minute warning off the deadline it already holds, best effort (a warning that cannot be armed never stops a tunnel), with its own request code so it cannot clobber the expiry alarm - the warning intent is handled above the command dispatch: without that it would fall through to the final else and stop the tunnel 5 minutes early - SessionTimer._doDisconnect takes endedMessage: set for ends the user did not ask for, which both posts the alert and gives the UI a sentence - the API being unreachable no longer disconnects a working tunnel; it is a display state now, with expiry still enforced natively and server-side - status_bar.dart renders connect errors, connecting progress and the session-end reason flutter analyze clean; debug APK builds (Kotlin compiles). Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
37753c6396
commit
742f37683e
7 files changed
+401
-26
No files matched your search
@@ -7,6 +7,24 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Five-minute warning before a session ends.** The VPN service schedules it
|
||||||
|
off the same deadline it already enforces, so the warning still arrives when
|
||||||
|
the app is closed, and a support top-up moves it with the deadline.
|
||||||
|
- **"Disconnected — not protected" notification** whenever a session ends for
|
||||||
|
any reason other than you tapping disconnect: expiry, the data limit, the
|
||||||
|
server ending the session, or the tunnel stopping on its own.
|
||||||
|
- **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 and by the server.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Rewarded ads no longer break connecting.** With ads enabled, the ad's
|
- **Rewarded ads no longer break connecting.** With ads enabled, the ad's
|
||||||
server-side-verification nonce and the nonce the connect flow waited for were
|
server-side-verification nonce and the nonce the connect flow waited for were
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import android.os.VibratorManager
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
@@ -61,14 +62,23 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
if (call.method == "updateNotificationText") {
|
when (call.method) {
|
||||||
val text = call.argument<String>("text") ?: ""
|
"updateNotificationText" -> {
|
||||||
val title = call.argument<String>("title") ?: "Revolt VPN"
|
val text = call.argument<String>("text") ?: ""
|
||||||
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
val title = call.argument<String>("title") ?: "Revolt VPN"
|
||||||
updateVpnNotification(title, text, action)
|
val action = call.argument<String>("disconnectLabel") ?: "Disconnect"
|
||||||
result.success(null)
|
updateVpnNotification(title, text, action)
|
||||||
} else {
|
result.success(null)
|
||||||
result.notImplemented()
|
}
|
||||||
|
// Session ends only Dart can see (cap exhausted, server
|
||||||
|
// ended the session, VPN error). The VPN service posts the
|
||||||
|
// same alert for its own deadline, where this process may
|
||||||
|
// already be gone. RevoltAlerts owns both.
|
||||||
|
"showDisconnectedAlert" -> {
|
||||||
|
RevoltAlerts.postDisconnected(this)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,44 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
|||||||
import 'package:revoltvpn/logic/session_timer.dart';
|
import 'package:revoltvpn/logic/session_timer.dart';
|
||||||
import 'package:revoltvpn/logic/vpn_connection.dart';
|
import 'package:revoltvpn/logic/vpn_connection.dart';
|
||||||
|
|
||||||
|
// Nothing rendered the connection's own messages, so they are collected here
|
||||||
|
// in one place: whatever the user most needs to know right now, and whether it
|
||||||
|
// is a problem (red) or just progress (dim).
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tunnel still carries traffic; only the control plane is missing, so
|
||||||
|
// this is a warning, not a disconnect (review #2).
|
||||||
|
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 {
|
class StatusBar extends StatelessWidget {
|
||||||
const StatusBar({super.key});
|
const StatusBar({super.key});
|
||||||
|
|
||||||
@@ -24,6 +62,8 @@ class StatusBar extends StatelessWidget {
|
|||||||
? 'Connecting…'
|
? 'Connecting…'
|
||||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||||
|
|
||||||
|
final detail = _detail(vpn, timer);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -96,6 +136,23 @@ class StatusBar extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Until this existed, a failed connect, an expired session and a
|
||||||
|
// cap kill all just turned the button back to idle - the user
|
||||||
|
// could not tell protected from unprotected (review #3).
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Posts the "disconnected - not protected" alert for a session end the user
|
||||||
|
// did not ask for. The native service posts the same alert (same id) when
|
||||||
|
// its own deadline fires, because by then this process may be gone; a double
|
||||||
|
// post replaces rather than stacks.
|
||||||
|
static Future<void> showDisconnectedAlert() async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod<void>('showDisconnectedAlert');
|
||||||
|
} catch (_) {
|
||||||
|
// Informational only - never worth failing a disconnect over.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Forces the next connect to re-post even if the countdown resumes on the
|
/// Forces the next connect to re-post even if the countdown resumes on the
|
||||||
/// same value.
|
/// same value.
|
||||||
static void reset() => _lastText = null;
|
static void reset() => _lastText = null;
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
static const FlutterSecureStorage _supportStorage = FlutterSecureStorage();
|
||||||
|
|
||||||
static const int _maxConsecutiveFailures = 3;
|
static const int _maxConsecutiveFailures = 3;
|
||||||
static const int _maxOfflineSeconds = 120;
|
|
||||||
int _offlineSeconds = 0;
|
|
||||||
|
|
||||||
static const int _pollIntervalSeconds = 60;
|
static const int _pollIntervalSeconds = 60;
|
||||||
static const int _backgroundPollIntervalSeconds = 60;
|
static const int _backgroundPollIntervalSeconds = 60;
|
||||||
@@ -67,6 +65,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
int get usedBytes => _usedBytes;
|
int get usedBytes => _usedBytes;
|
||||||
double get currentSpeedKBps => _currentSpeedKBps;
|
double get currentSpeedKBps => _currentSpeedKBps;
|
||||||
|
|
||||||
|
// True once the control plane has missed enough polls in a row to be worth
|
||||||
|
// telling the user about. It is a display state only: a tunnel that still
|
||||||
|
// carries traffic is never torn down because the API is unreachable (the
|
||||||
|
// native deadline and the server both still enforce expiry).
|
||||||
|
bool get serverUnreachable => _consecutiveFailures >= _maxConsecutiveFailures;
|
||||||
|
|
||||||
|
// Why the last session ended, when the user did not end it themselves.
|
||||||
|
// Cleared on the next connect. Rendered by status_bar.dart.
|
||||||
|
String? _lastEndMessage;
|
||||||
|
String? get lastEndMessage => _lastEndMessage;
|
||||||
|
|
||||||
String get formatted {
|
String get formatted {
|
||||||
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
final h = (_remainingSeconds ~/ 3600).toString().padLeft(2, '0');
|
||||||
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
final m = ((_remainingSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
|
||||||
@@ -124,11 +133,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
if (!hadActiveSession || _isDisconnecting) return;
|
if (!hadActiveSession || _isDisconnecting) return;
|
||||||
|
|
||||||
if (vpnConnection.status == VpnStatus.error) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vpnConnection.status == VpnStatus.disconnected) {
|
if (vpnConnection.status == VpnStatus.disconnected) {
|
||||||
|
// No alert here. The runtime going away covers both the native deadline
|
||||||
|
// (which posts the alert itself, from a process that outlives this one)
|
||||||
|
// and the notification's Disconnect action, which the user tapped.
|
||||||
unawaited(_doDisconnect('VPN tunnel dropped'));
|
unawaited(_doDisconnect('VPN tunnel dropped'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,9 +160,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_tickCount = 0;
|
_tickCount = 0;
|
||||||
_hasSyncedOnce = false;
|
_hasSyncedOnce = false;
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
_offlineSeconds = 0;
|
|
||||||
_isDisconnecting = false;
|
_isDisconnecting = false;
|
||||||
_appBackgrounded = false;
|
_appBackgrounded = false;
|
||||||
|
_lastEndMessage = null;
|
||||||
NotificationService.reset();
|
NotificationService.reset();
|
||||||
|
|
||||||
_supportStateEpoch++;
|
_supportStateEpoch++;
|
||||||
@@ -169,16 +184,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_reconcileElapsedTime();
|
_reconcileElapsedTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_consecutiveFailures >= _maxConsecutiveFailures) {
|
|
||||||
_offlineSeconds++;
|
|
||||||
if (_offlineSeconds >= _maxOfflineSeconds) {
|
|
||||||
unawaited(_doDisconnect('Server unreachable'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
if (_hasSyncedOnce && _remainingSeconds <= 0) {
|
||||||
unawaited(_doDisconnect('Session expired'));
|
unawaited(_doDisconnect(
|
||||||
|
'Session expired',
|
||||||
|
endedMessage: 'Your session ended. You are not protected.',
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,12 +229,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
await _doDisconnect(reason);
|
await _doDisconnect(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _doDisconnect(String reason) async {
|
// endedMessage is the one sentence the UI shows after an end the user did
|
||||||
|
// not ask for, and its presence is also what posts the "not protected"
|
||||||
|
// notification. Both exist so nobody is left on their real IP without being
|
||||||
|
// told. A user-requested disconnect passes nothing.
|
||||||
|
Future<void> _doDisconnect(String reason, {String? endedMessage}) async {
|
||||||
if (_isDisconnecting) return;
|
if (_isDisconnecting) return;
|
||||||
_isDisconnecting = true;
|
_isDisconnecting = true;
|
||||||
_sessionEpoch++;
|
_sessionEpoch++;
|
||||||
debugPrint('[Timer] Disconnecting: $reason');
|
debugPrint('[Timer] Disconnecting: $reason');
|
||||||
|
|
||||||
|
_lastEndMessage = endedMessage;
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
_currentSpeedKBps = 0.0;
|
_currentSpeedKBps = 0.0;
|
||||||
@@ -237,6 +252,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
await vpnConnection.disconnect();
|
await vpnConnection.disconnect();
|
||||||
|
if (endedMessage != null) {
|
||||||
|
unawaited(NotificationService.showDisconnectedAlert());
|
||||||
|
}
|
||||||
|
|
||||||
_isDisconnecting = false;
|
_isDisconnecting = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -282,7 +300,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!activeValue) {
|
if (!activeValue) {
|
||||||
await _doDisconnect('Server ended session');
|
await _doDisconnect(
|
||||||
|
'Server ended session',
|
||||||
|
endedMessage:
|
||||||
|
'The server ended your session. You are not protected.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +335,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
_sinceLastSuccessfulSync = Stopwatch()..start();
|
_sinceLastSuccessfulSync = Stopwatch()..start();
|
||||||
|
|
||||||
if (data['cap_exhausted'] == true) {
|
if (data['cap_exhausted'] == true) {
|
||||||
await _doDisconnect('Data cap reached');
|
await _doDisconnect(
|
||||||
|
'Data cap reached',
|
||||||
|
endedMessage: 'Data limit reached. You are not protected.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,12 +347,15 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync);
|
await vpnConnection.setNativeSessionDeadline(_remainingAtLastSync);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[Timer] Failed to arm native session deadline: $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;
|
return;
|
||||||
}
|
}
|
||||||
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
if (epoch != _sessionEpoch || _isDisconnecting) return;
|
||||||
_consecutiveFailures = 0;
|
_consecutiveFailures = 0;
|
||||||
_offlineSeconds = 0;
|
|
||||||
_hasSyncedOnce = true;
|
_hasSyncedOnce = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+107
@@ -16,6 +16,7 @@ import android.util.Log
|
|||||||
import com.github.tfox.flutter_vless.xray.core.XrayCoreManager
|
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.dto.XrayConfig
|
||||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||||
|
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -66,6 +67,14 @@ class XrayVPNService : VpnService() {
|
|||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Must stay above the command handling below: an intent carrying no
|
||||||
|
// COMMAND falls through to the final else and stops a live tunnel - in
|
||||||
|
// this case five minutes early, which is what it warns about.
|
||||||
|
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) {
|
val command = if (Build.VERSION.SDK_INT >= 33) {
|
||||||
intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
|
intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
|
||||||
} else {
|
} else {
|
||||||
@@ -121,6 +130,7 @@ class XrayVPNService : VpnService() {
|
|||||||
XrayCoreManager.stopCore(this)
|
XrayCoreManager.stopCore(this)
|
||||||
}
|
}
|
||||||
cleanup(clearDeadline = true)
|
cleanup(clearDeadline = true)
|
||||||
|
RevoltAlerts.cancelAll(this)
|
||||||
shuttingDownIntentionally = false
|
shuttingDownIntentionally = false
|
||||||
currentConfig = config
|
currentConfig = config
|
||||||
|
|
||||||
@@ -451,17 +461,81 @@ class XrayVPNService : VpnService() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
armExpiryWarning(config.RUNTIME_TOKEN, epochDeadline)
|
||||||
|
|
||||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||||
if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
|
if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
|
||||||
else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
|
else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedules the five-minute warning off the same deadline the expiry alarm
|
||||||
|
// holds. Unlike the deadline this is advisory: a warning that cannot be
|
||||||
|
// scheduled must never stop a working tunnel, so nothing here fails closed.
|
||||||
|
// Dart re-pushes the deadline on every sync, so this runs about once a minute.
|
||||||
|
private fun armExpiryWarning(runtimeToken: String, expiresAtEpochMs: Long) {
|
||||||
|
cancelSessionWarningAlarm()
|
||||||
|
if (runtimeToken.isEmpty()) return
|
||||||
|
|
||||||
|
val warnAt = expiresAtEpochMs - RevoltAlerts.WARNING_LEAD_MS
|
||||||
|
// Deadline already inside the warning window (or past): either the alarm
|
||||||
|
// has fired and its notification should stay up, or this is the short
|
||||||
|
// bootstrap deadline at connect, which must not warn at all.
|
||||||
|
if (warnAt <= System.currentTimeMillis()) return
|
||||||
|
|
||||||
|
// More than the lead away, so any warning on screen belongs to an older
|
||||||
|
// deadline - a support top-up just pushed it out.
|
||||||
|
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: minute-level slop is fine for a warning,
|
||||||
|
// and it keeps this off the SCHEDULE_EXACT_ALARM path.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSessionWarningAlarm(alarmToken: String) {
|
||||||
|
val config = currentConfig
|
||||||
|
if (config == null) {
|
||||||
|
// The alarm woke a service process with no live runtime; there is
|
||||||
|
// no session left 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 <= 0L || remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS) {
|
||||||
|
Log.w(TAG, "Ignoring session warning outside the warning window")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
RevoltAlerts.postExpiryWarning(this)
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearSessionDeadline() {
|
private fun clearSessionDeadline() {
|
||||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||||
sessionDeadlineElapsed = null
|
sessionDeadlineElapsed = null
|
||||||
sessionDeadlineToken = null
|
sessionDeadlineToken = null
|
||||||
clearPersistedDeadline()
|
clearPersistedDeadline()
|
||||||
cancelSessionExpiryAlarm()
|
cancelSessionExpiryAlarm()
|
||||||
|
cancelSessionWarningAlarm()
|
||||||
|
RevoltAlerts.cancelExpiryWarning(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enforceSessionDeadline() {
|
private fun enforceSessionDeadline() {
|
||||||
@@ -476,6 +550,10 @@ class XrayVPNService : VpnService() {
|
|||||||
}
|
}
|
||||||
Log.w(TAG, "Native session deadline expired; stopping runtime fail-closed")
|
Log.w(TAG, "Native session deadline expired; stopping runtime fail-closed")
|
||||||
stopAll(token)
|
stopAll(token)
|
||||||
|
// Only the deadline paths post this natively. A STOP_SERVICE command
|
||||||
|
// cannot tell a user tap from a Dart-side session end, so Dart posts for
|
||||||
|
// its own reasons; the shared id means a double post just replaces.
|
||||||
|
RevoltAlerts.postDisconnected(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateSessionDeadline(intent: Intent) {
|
private fun updateSessionDeadline(intent: Intent) {
|
||||||
@@ -561,9 +639,12 @@ class XrayVPNService : VpnService() {
|
|||||||
} else {
|
} else {
|
||||||
clearPersistedDeadline()
|
clearPersistedDeadline()
|
||||||
cancelSessionExpiryAlarm()
|
cancelSessionExpiryAlarm()
|
||||||
|
cancelSessionWarningAlarm()
|
||||||
|
RevoltAlerts.cancelExpiryWarning(this)
|
||||||
stopForeground(true)
|
stopForeground(true)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
RevoltAlerts.postDisconnected(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
|
private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
|
||||||
@@ -607,6 +688,27 @@ class XrayVPNService : VpnService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun cancelSessionWarningAlarm() {
|
||||||
|
try {
|
||||||
|
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
alarmManager.cancel(sessionWarningPendingIntent(""))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to cancel session-warning alarm", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A PendingIntent is identified by component plus request code, not extras,
|
||||||
|
// so the warning needs its own code or it would overwrite - and later
|
||||||
|
// cancel - 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 {
|
private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent {
|
||||||
val intent = Intent(this, XrayVPNService::class.java)
|
val intent = Intent(this, XrayVPNService::class.java)
|
||||||
.putExtra(EXTRA_SESSION_EXPIRED, true)
|
.putExtra(EXTRA_SESSION_EXPIRED, true)
|
||||||
@@ -681,6 +783,11 @@ class XrayVPNService : VpnService() {
|
|||||||
private const val PREF_RUNTIME_TOKEN = "runtime_token"
|
private const val PREF_RUNTIME_TOKEN = "runtime_token"
|
||||||
private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
|
private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
|
||||||
private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED"
|
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_EXPIRY_REQUEST_CODE = 1001
|
||||||
|
private const val SESSION_WARNING_REQUEST_CODE = 1002
|
||||||
|
// An idle-capable alarm can run late; accept that much drift before
|
||||||
|
// treating a warning as belonging to a deadline that has moved.
|
||||||
|
private const val WARNING_SLACK_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 user-facing session alerts, built in one place because two processes
|
||||||
|
// post them: XrayVPNService (:RunSoLibXrayDaemon) owns the native deadline and
|
||||||
|
// can still warn when the UI process is gone, while MainActivity posts the
|
||||||
|
// "not protected" alert for session ends only Dart can see (cap exhausted,
|
||||||
|
// server ended the session, VPN error). Both use the same ids, so whichever
|
||||||
|
// posts second just replaces the first notification instead of stacking.
|
||||||
|
//
|
||||||
|
// These are deliberately NOT the foreground-service notification (id 1,
|
||||||
|
// channel REVOLT_VPN_SERVICE, IMPORTANCE_LOW): an alert the user must notice
|
||||||
|
// needs its own high-importance channel, 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
|
||||||
|
|
||||||
|
// How long before the session deadline the warning fires.
|
||||||
|
const val WARNING_LEAD_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
|
private const val TAG = "RevoltAlerts"
|
||||||
|
private const val LAUNCH_REQUEST_CODE = 2001
|
||||||
|
|
||||||
|
fun postExpiryWarning(context: Context) {
|
||||||
|
post(
|
||||||
|
context,
|
||||||
|
WARNING_NOTIFICATION_ID,
|
||||||
|
"Revolt VPN disconnects in 5 minutes",
|
||||||
|
"Watch an ad 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)
|
||||||
|
|
||||||
|
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 when the channel is first created, so this asks for
|
||||||
|
// HIGH up front — a session alert the user never sees is the whole bug
|
||||||
|
// these notifications exist to fix.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user