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

@@ -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) {
@@ -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.
@@ -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) }