fix(session): repair the expiry warning and the gaps the review found

Review of 742f376 (Fable). Three defects made the feature not work for the
case it exists for:

- armExpiryWarning() cancelled the alarm before its own early return. Dart
  re-pushes the deadline every 60 s with no dedupe (rule 93's ">5 s" dedupe
  does not exist), so the first sync after the warning came due deleted the
  armed alarm and never re-armed it. Cancel now happens only on the branch
  that re-arms.
- cancelSessionWarningAlarm() built its PendingIntent with
  FLAG_UPDATE_CURRENT, rewriting the live alarm's RUNTIME_TOKEN to "" so a
  delivery in flight was dropped as a stale generation. It now looks the
  existing PendingIntent up with FLAG_NO_CREATE.
- SOCKS5 mode never requested POST_NOTIFICATIONS (requestPermission() is
  TUN-only because it also raises the VPN consent dialog), so on Android 13+
  both alerts were dropped. New plugin method requests it on its own.

Also:
- the native deadline wins the expiry race against the 1 s Dart tick, so the
  commonest session end reached _onVpnConnectionChanged as a bare tunnel
  drop with no message. It now reconciles and sets the expiry text, with
  postAlert false since the native side already posted.
- a resume reconnect no longer resets the support-ad claim: it re-attaches
  to the same server session, where the top-up has already been spent.
- RevoltAlerts sets setOnlyAlertOnce so a replacing post does not re-sound.
- AGENTS.md rule 93 described a setSessionExpiry/SESSION_EXPIRES_AT_MS path
  that no longer exists, dedupe included. Corrected; that stale text is what
  the warning bug was written against.
- CHANGELOG no longer claims the alert covers the tunnel stopping on its own.

flutter analyze clean; debug APK builds.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
esefxdzandClaude Opus 5 committed 2026-10-17 00:22:26 +03:00
1 parent 742f37683e
commit dd1082c8de
7 files changed
+96 -18

No files matched your search

+31 -11
View File
@@ -141,14 +141,28 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
}
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'));
// The native deadline fires on the exact second while this 1 s tick is
// in arbitrary phase, so expiry almost always arrives here rather than
// at the countdown check in _tick - without this, the commonest session
// end left the UI with nothing to say.
_reconcileElapsedTime();
final expired = _hasSyncedOnce && _remainingSeconds <= 2;
// postAlert is false either way: the native side already posted for its
// own deadline, and the notification's Disconnect action is the user
// asking, which must never raise a "not protected" alert.
unawaited(_doDisconnect(
'VPN tunnel dropped',
endedMessage:
expired ? 'Your session ended. You are not protected.' : null,
postAlert: false,
));
}
}
Future<void> start() async {
// resetSupportClaim is false when re-attaching to a session the device
// already holds: the support top-up is once per server session, and the
// claim flag is the only thing enforcing that.
Future<void> start({bool resetSupportClaim = true}) async {
_sessionEpoch++;
_remainingSeconds = 0;
_remainingAtLastSync = 0;
@@ -165,10 +179,12 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
_lastEndMessage = null;
NotificationService.reset();
_supportStateEpoch++;
_supportRewardClaimed = false;
_supportRewardStateLoaded = true;
unawaited(_persistSupportRewardState(false));
if (resetSupportClaim) {
_supportStateEpoch++;
_supportRewardClaimed = false;
_supportRewardStateLoaded = true;
unawaited(_persistSupportRewardState(false));
}
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
@@ -233,7 +249,11 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
// 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 {
Future<void> _doDisconnect(
String reason, {
String? endedMessage,
bool postAlert = true,
}) async {
if (_isDisconnecting) return;
_isDisconnecting = true;
_sessionEpoch++;
@@ -252,7 +272,7 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
notifyListeners();
await vpnConnection.disconnect();
if (endedMessage != null) {
if (endedMessage != null && postAlert) {
unawaited(NotificationService.showDisconnectedAlert());
}
+12
View File
@@ -224,6 +224,18 @@ class VpnConnection extends ChangeNotifier {
return false;
}
// SOCKS5 mode skips requestPermission below (it would raise the VPN
// consent dialog for an interface it never creates), so ask for the
// notification permission on its own - the session alerts need it.
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;
@@ -82,7 +82,7 @@ class _ConnectionModeTileState extends State<ConnectionModeTile> {
if (!mounted) return;
if (await vpn.connect(resume: true)) {
if (!mounted) return;
await timer.start();
await timer.start(resetSupportClaim: false);
}
} finally {
if (mounted) {