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
@@ -5,6 +5,44 @@ import 'package:revoltvpn/logic/app_colors.dart';
|
||||
import 'package:revoltvpn/logic/session_timer.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 {
|
||||
const StatusBar({super.key});
|
||||
|
||||
@@ -24,6 +62,8 @@ class StatusBar extends StatelessWidget {
|
||||
? 'Connecting…'
|
||||
: (vpn.serverReachable ? 'Ready' : 'Offline'));
|
||||
|
||||
final detail = _detail(vpn, timer);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
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
|
||||
/// same value.
|
||||
static void reset() => _lastText = null;
|
||||
|
||||
@@ -36,8 +36,6 @@ 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;
|
||||
@@ -67,6 +65,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
int get usedBytes => _usedBytes;
|
||||
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 {
|
||||
final h = (_remainingSeconds ~/ 3600).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 (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) {
|
||||
// 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'));
|
||||
}
|
||||
}
|
||||
@@ -145,9 +160,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
_tickCount = 0;
|
||||
_hasSyncedOnce = false;
|
||||
_consecutiveFailures = 0;
|
||||
_offlineSeconds = 0;
|
||||
_isDisconnecting = false;
|
||||
_appBackgrounded = false;
|
||||
_lastEndMessage = null;
|
||||
NotificationService.reset();
|
||||
|
||||
_supportStateEpoch++;
|
||||
@@ -169,16 +184,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;
|
||||
}
|
||||
|
||||
@@ -219,12 +229,17 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
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;
|
||||
_isDisconnecting = true;
|
||||
_sessionEpoch++;
|
||||
debugPrint('[Timer] Disconnecting: $reason');
|
||||
|
||||
_lastEndMessage = endedMessage;
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentSpeedKBps = 0.0;
|
||||
@@ -237,6 +252,9 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
|
||||
notifyListeners();
|
||||
|
||||
await vpnConnection.disconnect();
|
||||
if (endedMessage != null) {
|
||||
unawaited(NotificationService.showDisconnectedAlert());
|
||||
}
|
||||
|
||||
_isDisconnecting = false;
|
||||
notifyListeners();
|
||||
@@ -282,7 +300,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;
|
||||
}
|
||||
|
||||
@@ -313,7 +335,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 +347,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 {
|
||||
|
||||
Reference in new issue
Block a user