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
+107
@@ -16,6 +16,7 @@ import android.util.Log
|
||||
import com.github.tfox.flutter_vless.xray.core.XrayCoreManager
|
||||
import com.github.tfox.flutter_vless.xray.dto.XrayConfig
|
||||
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
|
||||
import com.github.tfox.flutter_vless.xray.utils.RevoltAlerts
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
@@ -66,6 +67,14 @@ class XrayVPNService : VpnService() {
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
// Must stay above the command handling below: an intent carrying no
|
||||
// COMMAND falls through to the final else and stops a live tunnel - in
|
||||
// this case five minutes early, which is what it warns about.
|
||||
if (intent.getBooleanExtra(EXTRA_SESSION_WARNING, false)) {
|
||||
handleSessionWarningAlarm(intent.getStringExtra("RUNTIME_TOKEN").orEmpty())
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val command = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getSerializableExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS::class.java)
|
||||
} else {
|
||||
@@ -121,6 +130,7 @@ class XrayVPNService : VpnService() {
|
||||
XrayCoreManager.stopCore(this)
|
||||
}
|
||||
cleanup(clearDeadline = true)
|
||||
RevoltAlerts.cancelAll(this)
|
||||
shuttingDownIntentionally = false
|
||||
currentConfig = config
|
||||
|
||||
@@ -451,17 +461,81 @@ class XrayVPNService : VpnService() {
|
||||
return
|
||||
}
|
||||
|
||||
armExpiryWarning(config.RUNTIME_TOKEN, epochDeadline)
|
||||
|
||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||
if (delayMs == 0L) deadlineHandler.post(sessionDeadlineRunnable)
|
||||
else deadlineHandler.postDelayed(sessionDeadlineRunnable, delayMs)
|
||||
}
|
||||
|
||||
// Schedules the five-minute warning off the same deadline the expiry alarm
|
||||
// holds. Unlike the deadline this is advisory: a warning that cannot be
|
||||
// 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.
|
||||
if (warnAt <= System.currentTimeMillis()) return
|
||||
|
||||
// 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)
|
||||
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val pendingIntent = sessionWarningPendingIntent(runtimeToken)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
// Inexact on purpose: minute-level slop is fine for a warning,
|
||||
// and it keeps this off the SCHEDULE_EXACT_ALARM path.
|
||||
alarmManager.setAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
warnAt,
|
||||
pendingIntent,
|
||||
)
|
||||
} else {
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, warnAt, pendingIntent)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to schedule session-expiry warning", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSessionWarningAlarm(alarmToken: String) {
|
||||
val config = currentConfig
|
||||
if (config == null) {
|
||||
// The alarm woke a service process with no live runtime; there is
|
||||
// no session left to warn about.
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
if (alarmToken.isEmpty() || alarmToken != config.RUNTIME_TOKEN) {
|
||||
Log.w(TAG, "Ignoring session warning for stale runtime generation")
|
||||
return
|
||||
}
|
||||
|
||||
val deadline = sessionDeadlineElapsed ?: return
|
||||
val remaining = deadline - SystemClock.elapsedRealtime()
|
||||
if (remaining <= 0L || remaining > RevoltAlerts.WARNING_LEAD_MS + WARNING_SLACK_MS) {
|
||||
Log.w(TAG, "Ignoring session warning outside the warning window")
|
||||
return
|
||||
}
|
||||
|
||||
RevoltAlerts.postExpiryWarning(this)
|
||||
}
|
||||
|
||||
private fun clearSessionDeadline() {
|
||||
deadlineHandler.removeCallbacks(sessionDeadlineRunnable)
|
||||
sessionDeadlineElapsed = null
|
||||
sessionDeadlineToken = null
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
cancelSessionWarningAlarm()
|
||||
RevoltAlerts.cancelExpiryWarning(this)
|
||||
}
|
||||
|
||||
private fun enforceSessionDeadline() {
|
||||
@@ -476,6 +550,10 @@ class XrayVPNService : VpnService() {
|
||||
}
|
||||
Log.w(TAG, "Native session deadline expired; stopping runtime fail-closed")
|
||||
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) {
|
||||
@@ -561,9 +639,12 @@ class XrayVPNService : VpnService() {
|
||||
} else {
|
||||
clearPersistedDeadline()
|
||||
cancelSessionExpiryAlarm()
|
||||
cancelSessionWarningAlarm()
|
||||
RevoltAlerts.cancelExpiryWarning(this)
|
||||
stopForeground(true)
|
||||
stopSelf()
|
||||
}
|
||||
RevoltAlerts.postDisconnected(this)
|
||||
}
|
||||
|
||||
private fun scheduleSessionExpiryAlarm(runtimeToken: String, expiresAtEpochMs: Long): Boolean {
|
||||
@@ -607,6 +688,27 @@ class XrayVPNService : VpnService() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSessionWarningAlarm() {
|
||||
try {
|
||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
alarmManager.cancel(sessionWarningPendingIntent(""))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to cancel session-warning alarm", e)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
private fun sessionWarningPendingIntent(runtimeToken: String): PendingIntent {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
.putExtra(EXTRA_SESSION_WARNING, true)
|
||||
.putExtra("RUNTIME_TOKEN", runtimeToken)
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT 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)
|
||||
}
|
||||
|
||||
private fun sessionExpiryPendingIntent(runtimeToken: String): PendingIntent {
|
||||
val intent = Intent(this, XrayVPNService::class.java)
|
||||
.putExtra(EXTRA_SESSION_EXPIRED, true)
|
||||
@@ -681,6 +783,11 @@ class XrayVPNService : VpnService() {
|
||||
private const val PREF_RUNTIME_TOKEN = "runtime_token"
|
||||
private const val PREF_EXPIRES_AT_MS = "expires_at_ms"
|
||||
private const val EXTRA_SESSION_EXPIRED = "SESSION_EXPIRED"
|
||||
private const val EXTRA_SESSION_WARNING = "SESSION_WARNING"
|
||||
private const val SESSION_EXPIRY_REQUEST_CODE = 1001
|
||||
private const val SESSION_WARNING_REQUEST_CODE = 1002
|
||||
// An idle-capable alarm can run late; accept that much drift before
|
||||
// treating a warning as belonging to a deadline that has moved.
|
||||
private const val WARNING_SLACK_MS = 60_000L
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.github.tfox.flutter_vless.xray.utils
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
// local_packages/.../xray/utils/RevoltAlerts.kt
|
||||
//
|
||||
// The two user-facing session alerts, built in one place because two processes
|
||||
// post them: XrayVPNService (:RunSoLibXrayDaemon) owns the native deadline and
|
||||
// can still warn when the UI process is gone, while MainActivity posts the
|
||||
// "not protected" alert for session ends only Dart can see (cap exhausted,
|
||||
// server ended the session, VPN error). Both use the same ids, so whichever
|
||||
// posts second just replaces the first notification instead of stacking.
|
||||
//
|
||||
// These are deliberately NOT the foreground-service notification (id 1,
|
||||
// channel REVOLT_VPN_SERVICE, IMPORTANCE_LOW): an alert the user must notice
|
||||
// needs its own high-importance channel, and must be dismissible.
|
||||
object RevoltAlerts {
|
||||
|
||||
const val CHANNEL_ID = "REVOLT_VPN_ALERTS"
|
||||
const val WARNING_NOTIFICATION_ID = 2
|
||||
const val DISCONNECTED_NOTIFICATION_ID = 3
|
||||
|
||||
// How long before the session deadline the warning fires.
|
||||
const val WARNING_LEAD_MS = 5 * 60 * 1000L
|
||||
|
||||
private const val TAG = "RevoltAlerts"
|
||||
private const val LAUNCH_REQUEST_CODE = 2001
|
||||
|
||||
fun postExpiryWarning(context: Context) {
|
||||
post(
|
||||
context,
|
||||
WARNING_NOTIFICATION_ID,
|
||||
"Revolt VPN disconnects in 5 minutes",
|
||||
"Watch an ad to extend your session.",
|
||||
)
|
||||
}
|
||||
|
||||
fun postDisconnected(context: Context) {
|
||||
post(
|
||||
context,
|
||||
DISCONNECTED_NOTIFICATION_ID,
|
||||
"Revolt VPN disconnected",
|
||||
"Your traffic is no longer protected. Tap to reconnect.",
|
||||
)
|
||||
}
|
||||
|
||||
fun cancelExpiryWarning(context: Context) = cancel(context, WARNING_NOTIFICATION_ID)
|
||||
|
||||
fun cancelDisconnected(context: Context) = cancel(context, DISCONNECTED_NOTIFICATION_ID)
|
||||
|
||||
fun cancelAll(context: Context) {
|
||||
cancelExpiryWarning(context)
|
||||
cancelDisconnected(context)
|
||||
}
|
||||
|
||||
private fun cancel(context: Context, id: Int) {
|
||||
try {
|
||||
manager(context)?.cancel(id)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to cancel alert $id", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun post(context: Context, id: Int, title: String, text: String) {
|
||||
if (!canPost(context)) {
|
||||
Log.w(TAG, "POST_NOTIFICATIONS not granted; dropping alert $id")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val manager = manager(context) ?: return
|
||||
ensureChannel(manager)
|
||||
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(context, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(context).setPriority(Notification.PRIORITY_HIGH)
|
||||
}
|
||||
|
||||
val icon = context.resources
|
||||
.getIdentifier("notification_icon", "drawable", context.packageName)
|
||||
.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info
|
||||
|
||||
builder
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setStyle(Notification.BigTextStyle().bigText(text))
|
||||
.setSmallIcon(icon)
|
||||
.setAutoCancel(true)
|
||||
.setOngoing(false)
|
||||
|
||||
launchIntent(context)?.let { builder.setContentIntent(it) }
|
||||
|
||||
manager.notify(id, builder.build())
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to post alert $id", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPost(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
||||
return context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun manager(context: Context): NotificationManager? =
|
||||
context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||
|
||||
// Importance is fixed when the channel is first created, so this asks for
|
||||
// HIGH up front — a session alert the user never sees is the whole bug
|
||||
// these notifications exist to fix.
|
||||
private fun ensureChannel(manager: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Session alerts",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
)
|
||||
channel.description = "Warns before a session ends and when protection stops."
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun launchIntent(context: Context): PendingIntent? {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?: return null
|
||||
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
||||
return PendingIntent.getActivity(context, LAUNCH_REQUEST_CODE, intent, flags)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user