From dd1082c8de4ae2cca23ed5cdbfe4b5df7b679ab4 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Sat, 17 Oct 2026 00:22:26 +0300 Subject: [PATCH] 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 --- CHANGELOG.md | 9 ++-- lib/logic/session_timer.dart | 42 ++++++++++++++----- lib/logic/vpn_connection.dart | 12 ++++++ .../settings/in_settings/connection_mode.dart | 2 +- .../tfox/flutter_vless/FlutterVlessPlugin.kt | 25 +++++++++++ .../xray/service/XrayVPNService.kt | 21 ++++++++-- .../flutter_vless/xray/utils/RevoltAlerts.kt | 3 ++ 7 files changed, 96 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ca2eae..370e878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,12 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH - **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. +- **"Disconnected — not protected" notification** when a session ends without + you asking: the 2-hour expiry, the data limit, the server ending the session, + or the tunnel reporting an error. +- **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. diff --git a/lib/logic/session_timer.dart b/lib/logic/session_timer.dart index c205da0..a7cfa3c 100644 --- a/lib/logic/session_timer.dart +++ b/lib/logic/session_timer.dart @@ -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 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 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 _doDisconnect(String reason, {String? endedMessage}) async { + Future _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()); } diff --git a/lib/logic/vpn_connection.dart b/lib/logic/vpn_connection.dart index 44d0e30..78150d2 100644 --- a/lib/logic/vpn_connection.dart +++ b/lib/logic/vpn_connection.dart @@ -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('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; diff --git a/lib/screens/settings/in_settings/connection_mode.dart b/lib/screens/settings/in_settings/connection_mode.dart index c638e66..0d5e879 100644 --- a/lib/screens/settings/in_settings/connection_mode.dart +++ b/lib/screens/settings/in_settings/connection_mode.dart @@ -82,7 +82,7 @@ class _ConnectionModeTileState extends State { if (!mounted) return; if (await vpn.connect(resume: true)) { if (!mounted) return; - await timer.start(); + await timer.start(resetSupportClaim: false); } } finally { if (mounted) { diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt index 35cc1f7..e71c891 100644 --- a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/FlutterVlessPlugin.kt @@ -240,6 +240,31 @@ class FlutterVlessPlugin : FlutterPlugin, ActivityAware, PluginRegistry.Activity } } + // POST_NOTIFICATIONS on its own, without VpnService.prepare: SOCKS5 + // mode never establishes a VPN interface, so it never called + // requestPermission and both session alerts were dropped unasked + // for on Android 13+. + "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) { diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt index bedb116..9ef07e0 100644 --- a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/service/XrayVPNService.kt @@ -473,15 +473,18 @@ class XrayVPNService : VpnService() { // 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. + // bootstrap deadline at connect, which must not warn at all. Nothing is + // cancelled here - Dart re-pushes the same deadline every 60 s with no + // dedupe, so cancelling on this branch would reliably delete the armed + // warning on the first sync after it came due and never re-arm it. if (warnAt <= System.currentTimeMillis()) return + cancelSessionWarningAlarm() // 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) @@ -691,12 +694,24 @@ class XrayVPNService : VpnService() { private fun cancelSessionWarningAlarm() { try { val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmManager.cancel(sessionWarningPendingIntent("")) + // Look the existing PendingIntent up instead of building one: with + // FLAG_UPDATE_CURRENT, constructing it to cancel rewrites the live + // alarm's RUNTIME_TOKEN to "" first, and a delivery already in + // flight would then be dropped as a stale generation. + 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) + } + // 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. diff --git a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt index fc085fa..48ada2e 100644 --- a/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt +++ b/local_packages/flutter_vless_android-1.1.5/android/src/main/kotlin/com/github/tfox/flutter_vless/xray/utils/RevoltAlerts.kt @@ -97,6 +97,9 @@ object RevoltAlerts { .setSmallIcon(icon) .setAutoCancel(true) .setOngoing(false) + // Both processes can post the disconnected alert; the second + // post replaces the first and must not sound again. + .setOnlyAlertOnce(true) launchIntent(context)?.let { builder.setContentIntent(it) }