fix(session): alert on every unsolicited native stop, not just the deadline

Second review pass found that VpnStatus.error is unreachable mid-session:
AppConfigs.V2RAY_STATES has no error state and the plugin's stateName when()
ends in else -> "DISCONNECTED", so Dart's unknown -> error mapping is dead
code on Android. Every native stop therefore arrived as a plain disconnect,
and anything other than the expiry deadline - an Xray or TUN setup failure, a
mid-session fail-closed deadline arm - left the user on their real IP with no
notification and no message. That is the exact hole this feature exists to
close.

stopAll() is the single funnel for every native stop, so the alert moves
there: it fires when the stop interrupted a live tunnel and the user did not
ask for it. isRunning is captured before cleanup() clears it, which keeps a
failed connect silent - it never had a tunnel to interrupt. STOP_SERVICE
(the notification's Disconnect action) and onRevoke pass userRequested: in
the revoke case the user switched VPNs or withdrew consent themselves, and
may well be protected by the app that took over.

Also from the pass:
- re-arming now needs the deadline to move by more than 15 s, so round-trip
  jitter cannot cancel and re-post a warning that is already on screen
- a warning delivered with under a minute left is dropped rather than
  announcing "5 minutes"; the disconnected alert follows anyway
- the _doDisconnect comment no longer claims endedMessage posts the alert
- CHANGELOG: the alert does now cover the tunnel stopping unexpectedly

flutter analyze clean; debug APK builds with the Kotlin recompiled.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
esefxdzandClaude Opus 5 committed 2026-10-17 00:44:51 +03:00
1 parent d1ea13b01a
commit 8ba3b6c543
4 files changed
+43 -24

No files matched your search

+2 -2
View File
@@ -12,8 +12,8 @@ Versions follow [semver](https://semver.org/): MAJOR.MINOR.PATCH
off the same deadline it already enforces, so the warning still arrives when 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. the app is closed, and a support top-up moves it with the deadline.
- **"Disconnected — not protected" notification** when a session ends without - **"Disconnected — not protected" notification** when a session ends without
you asking: the 2-hour expiry, the data limit, or the server ending the you asking: the 2-hour expiry, the data limit, the server ending the
session. session, or the tunnel stopping unexpectedly.
- **SOCKS5 mode now asks for the notification permission.** It never did, - **SOCKS5 mode now asks for the notification permission.** It never did,
because it never raises the VPN consent dialog, so on Android 13+ these because it never raises the VPN consent dialog, so on Android 13+ these
alerts would have been dropped before they were shown. alerts would have been dropped before they were shown.
+4 -3
View File
@@ -256,9 +256,10 @@ class SessionTimer extends ChangeNotifier with WidgetsBindingObserver {
} }
// endedMessage is the one sentence the UI shows after an end the user did // 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" // not ask for; postAlert independently decides whether to raise the "not
// notification. Both exist so nobody is left on their real IP without being // protected" notification, which the native service already posts for every
// told. A user-requested disconnect passes nothing. // stop it sees. Both exist so nobody is left on their real IP without being
// told. A user-requested disconnect passes neither.
Future<void> _doDisconnect( Future<void> _doDisconnect(
String reason, { String reason, {
String? endedMessage, String? endedMessage,
@@ -91,7 +91,7 @@ class XrayVPNService : VpnService() {
Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation") Log.w(TAG, "Ignoring STOP_SERVICE without matching runtime generation")
return START_NOT_STICKY return START_NOT_STICKY
} }
stopAll(requestedToken) stopAll(requestedToken, userRequested = true)
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -428,14 +428,25 @@ class XrayVPNService : VpnService() {
} }
} }
private fun stopAll(confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty()) { // userRequested marks the two stops the user drove themselves. Everything
// else that reaches here while a tunnel is live - the expiry deadline, an
// Xray or TUN failure, a fail-closed deadline arm - took protection away
// without being asked, and must say so. isRunning is read before cleanup()
// clears it, which is also what keeps a failed connect silent: it never had
// a live tunnel to interrupt.
private fun stopAll(
confirmationToken: String = currentConfig?.RUNTIME_TOKEN.orEmpty(),
userRequested: Boolean = false,
) {
if (shuttingDownIntentionally) return if (shuttingDownIntentionally) return
val alertUser = isRunning && !userRequested
shuttingDownIntentionally = true shuttingDownIntentionally = true
cleanup(clearDeadline = true) cleanup(clearDeadline = true)
currentConfig = null currentConfig = null
XrayCoreManager.stopCore(this, confirmationToken) XrayCoreManager.stopCore(this, confirmationToken)
stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
if (alertUser) RevoltAlerts.postDisconnected(this)
} }
private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) { private fun armSessionDeadline(config: XrayConfig, remainingSeconds: Long) {
@@ -482,7 +493,7 @@ class XrayVPNService : VpnService() {
// cancelled here - Dart re-pushes the same deadline every 60 s with no // cancelled here - Dart re-pushes the same deadline every 60 s with no
// dedupe, so cancelling on this branch would reliably delete the armed // 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. // warning on the first sync after it came due and never re-arm it.
if (warnAt <= System.currentTimeMillis()) return if (warnAt <= System.currentTimeMillis() + WARNING_REARM_GRACE_MS) return
cancelSessionWarningAlarm() cancelSessionWarningAlarm()
// More than the lead away, so any warning on screen belongs to an older // More than the lead away, so any warning on screen belongs to an older
@@ -523,7 +534,12 @@ class XrayVPNService : VpnService() {
val deadline = sessionDeadlineElapsed ?: return val deadline = sessionDeadlineElapsed ?: return
val remaining = deadline - SystemClock.elapsedRealtime() val remaining = deadline - SystemClock.elapsedRealtime()
if (remaining <= 0L || remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS) { if (remaining < WARNING_FLOOR_MS ||
remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS
) {
// Too late to be a five-minute warning, or for a deadline that has
// moved. Saying "5 minutes" with seconds left is worse than silence;
// the disconnected alert follows either way.
Log.w(TAG, "Ignoring session warning outside the warning window") Log.w(TAG, "Ignoring session warning outside the warning window")
return return
} }
@@ -553,10 +569,6 @@ 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) {
@@ -646,8 +658,9 @@ class XrayVPNService : VpnService() {
RevoltAlerts.cancelExpiryWarning(this) RevoltAlerts.cancelExpiryWarning(this)
stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
// This arm does not go through stopAll, so it posts for itself.
RevoltAlerts.postDisconnected(this)
} }
RevoltAlerts.postDisconnected(this)
} }
private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean { private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
@@ -745,7 +758,7 @@ class XrayVPNService : VpnService() {
} }
override fun onRevoke() { override fun onRevoke() {
stopAll() stopAll(userRequested = true)
super.onRevoke() super.onRevoke()
} }
@@ -804,5 +817,10 @@ class XrayVPNService : VpnService() {
// An idle-capable alarm can run late; accept that much drift before // An idle-capable alarm can run late; accept that much drift before
// treating a warning as belonging to a deadline that has moved. // treating a warning as belonging to a deadline that has moved.
private const val WARNING_SLACK_MS = 60_000L private const val WARNING_SLACK_MS = 60_000L
// Don't re-arm (and so re-post) for a deadline that only moved by the
// jitter in a network round trip.
private const val WARNING_REARM_GRACE_MS = 15_000L
// Below this the warning has lost its point; stay quiet instead.
private const val WARNING_FLOOR_MS = 60_000L
} }
} }
+9 -9
View File
@@ -299,10 +299,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.20" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -315,10 +315,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.0" version: "1.18.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -560,10 +560,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -648,10 +648,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_math name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2" version: "2.2.0"
vm_service: vm_service:
dependency: transitive dependency: transitive
description: description:
@@ -725,5 +725,5 @@ packages:
source: hosted source: hosted
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.11.0-0 <4.0.0" dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.38.4"