fix(app): align Android VPN runtime integration

Remove obsolete UI-process runtime plumbing and reuse the native service-owned notification and generation-scoped control path.
This commit is contained in:
Naveax committed 2026-09-04 01:14:50 +03:00
1 parent 9ac236a981
commit 4bb81567f2
3 files changed
+25 -99

No files matched your search

-3
View File
@@ -10,9 +10,6 @@
<!-- Doze/App Standby stop background services of non-exempt apps. A VPN <!-- Doze/App Standby stop background services of non-exempt apps. A VPN
cannot stay up without this; Play allows it for VPN functionality. --> cannot stay up without this; Play allows it for VPN functionality. -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/> <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<!-- OS-level session-expiry alarm. Auto-granted on 31-32; on 33+ the daemon
falls back to setAndAllowWhileIdle if exact alarms aren't granted. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. --> <!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
<uses-permission <uses-permission
@@ -3,7 +3,6 @@ package com.paladinvpn.app
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.ActivityManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -21,8 +20,6 @@ import android.os.VibratorManager
import android.provider.Settings import android.provider.Settings
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
import com.github.tfox.flutter_vless.xray.utils.AppConfigs
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
@@ -54,24 +51,10 @@ class MainActivity : FlutterActivity() {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
when (call.method) { when (call.method) {
// Doze and App Standby stop background services of apps that
// are not exempt. A VPN has to be exempt to survive the screen
// going off, so surface the real state rather than guessing.
"isIgnoringBatteryOptimizations" -> "isIgnoringBatteryOptimizations" ->
result.success(isIgnoringBatteryOptimizations()) result.success(isIgnoringBatteryOptimizations())
"requestIgnoreBatteryOptimizations" -> "requestIgnoreBatteryOptimizations" ->
result.success(requestIgnoreBatteryOptimizations()) result.success(requestIgnoreBatteryOptimizations())
"setSessionExpiry" -> {
val expiresAtMs = call.argument<Number>("expiresAtMs")?.toLong() ?: 0L
if (expiresAtMs > 0) {
sendSessionExpiryToDaemon(expiresAtMs)
}
result.success(null)
}
// Is our VPN runtime actually alive? The service lives in
// :RunSoLibXrayDaemon, a separate process, so its statics are
// NOT visible here -- this is the only honest check.
"isVpnRuntimeAlive" -> result.success(isVpnRuntimeAlive())
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -118,7 +101,6 @@ class MainActivity : FlutterActivity() {
super.onDestroy() super.onDestroy()
} }
/** True when the OS has exempted us from Doze / App Standby. */
private fun isIgnoringBatteryOptimizations(): Boolean { private fun isIgnoringBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
return try { return try {
@@ -129,11 +111,6 @@ class MainActivity : FlutterActivity() {
} }
} }
/**
* Opens the system exemption prompt. Android shows this as a single dialog;
* it cannot be granted silently, so the caller must treat a false return as
* "ask again later" rather than a denial.
*/
private fun requestIgnoreBatteryOptimizations(): Boolean { private fun requestIgnoreBatteryOptimizations(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
if (isIgnoringBatteryOptimizations()) return true if (isIgnoringBatteryOptimizations()) return true
@@ -146,7 +123,6 @@ class MainActivity : FlutterActivity() {
) )
true true
} catch (_: Exception) { } catch (_: Exception) {
// Some OEM builds hide the direct intent; fall back to the list.
try { try {
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
true true
@@ -156,58 +132,6 @@ class MainActivity : FlutterActivity() {
} }
} }
/** Pushes the session-expiry deadline to the VPN service process. */
private fun sendSessionExpiryToDaemon(expiresAtMs: Long) {
try {
val intent = Intent(this, XrayVPNService::class.java)
intent.putExtra("SESSION_EXPIRES_AT_MS", expiresAtMs)
startService(intent)
} catch (_: Exception) {
// Daemon not reachable; its persisted expiry still applies.
}
}
/**
* Whether the VPN runtime is genuinely alive.
*
* The Xray service runs in :RunSoLibXrayDaemon, a separate process, so its
* statics (AppConfigs.V2RAY_CONFIG, V2RAY_STATE) are NOT visible from here --
* reading them yields a fresh, empty copy. getRunningAppProcesses still
* returns our *own* processes, which makes it the one reliable signal.
*/
private fun isVpnRuntimeAlive(): Map<String, Any> {
var processAlive = false
try {
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
processAlive = am.runningAppProcesses
?.any { it.processName.endsWith(VPN_PROCESS_SUFFIX) } == true
} catch (_: Exception) {
}
var vpnTransport = false
try {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
vpnTransport = cm.allNetworks.any { network ->
cm.getNetworkCapabilities(network)
?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
}
} catch (_: Exception) {
}
return mapOf(
"processAlive" to processAlive,
"vpnTransport" to vpnTransport,
)
}
/** Removes a notification left behind by a runtime that is no longer alive. */
private fun cancelVpnNotification() {
try {
NotificationManagerCompat.from(this).cancel(NOTIFICATION_ID)
} catch (_: Exception) {
}
}
private fun updateVpnNotification(title: String, text: String, actionLabel: String) { private fun updateVpnNotification(title: String, text: String, actionLabel: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
@@ -216,15 +140,6 @@ class MainActivity : FlutterActivity() {
return return
} }
// The foreground notification (id 1) is owned by XrayVPNService via
// startForeground. Posting to it from this process while the service is
// gone leaves a frozen countdown nothing will ever update again.
val alive = isVpnRuntimeAlive()["processAlive"] == true
if (!alive) {
cancelVpnNotification()
return
}
ensureNotificationChannel() ensureNotificationChannel()
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
@@ -234,17 +149,28 @@ class MainActivity : FlutterActivity() {
} }
val launchIntent = packageManager.getLaunchIntentForPackage(packageName) val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
launchIntent?.action = "FROM_DISCONNECT_BTN"
launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or launchIntent?.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_NEW_TASK Intent.FLAG_ACTIVITY_NEW_TASK
val stopIntent = Intent(this, XrayVPNService::class.java) // The VPN service owns the authoritative Disconnect PendingIntent and
stopIntent.putExtra("COMMAND", AppConfigs.V2RAY_SERVICE_COMMANDS.STOP_SERVICE) // embeds the current runtime-generation token in it. Never recreate an
val stopPendingIntent = PendingIntent.getService(this, 0, stopIntent, flags) // unscoped STOP intent from the UI process: a stale notification could
// otherwise stop a newer tunnel. Reuse the live service action when the
// platform exposes it; on older Android versions omit the action rather
// than weaken generation scoping.
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val serviceStopAction = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
manager.activeNotifications
.firstOrNull { it.id == NOTIFICATION_ID }
?.notification
?.actions
?.firstOrNull()
?.actionIntent
} else {
null
}
// Public lock-screen preview: keep the VPN status visible but hide the
// session countdown/speed so usage metadata never reaches the lock screen.
val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) val publicVersion = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.notification_status_icon) .setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle("Revolt VPN") .setContentTitle("Revolt VPN")
@@ -255,7 +181,6 @@ class MainActivity : FlutterActivity() {
.setSmallIcon(R.drawable.notification_status_icon) .setSmallIcon(R.drawable.notification_status_icon)
.setContentTitle(title) .setContentTitle(title)
.setContentText(text) .setContentText(text)
.addAction(0, actionLabel, stopPendingIntent)
.setColorized(true) .setColorized(true)
.setColor(0xFF0D1117.toInt()) .setColor(0xFF0D1117.toInt())
.setOngoing(true) .setOngoing(true)
@@ -267,6 +192,10 @@ class MainActivity : FlutterActivity() {
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion) .setPublicVersion(publicVersion)
if (serviceStopAction != null) {
builder.addAction(0, actionLabel, serviceStopAction)
}
if (launchIntent != null) { if (launchIntent != null) {
builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags)) builder.setContentIntent(PendingIntent.getActivity(this, 0, launchIntent, flags))
} }
@@ -473,7 +402,6 @@ class MainActivity : FlutterActivity() {
companion object { companion object {
private const val NOTIFICATION_ID = 1 private const val NOTIFICATION_ID = 1
private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon" private const val NOTIFICATION_CHANNEL_ID = "REVOLT_VPN_SERVICE"
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
} }
} }
+3 -2
View File
@@ -16,16 +16,17 @@ class NotificationService {
/// Sets the notification's content text to [text]. /// Sets the notification's content text to [text].
/// ///
/// The native side rebuilds the whole notification, so skip the round-trip /// The native side rebuilds the whole notification, so skip the round-trip
/// when the text has not changed. /// when the text has not changed. A failed native update is intentionally
/// not cached so the next tick can retry it.
static Future<void> updateTimer(String text) async { static Future<void> updateTimer(String text) async {
if (text == _lastText) return; if (text == _lastText) return;
_lastText = text;
try { try {
await _channel.invokeMethod<void>('updateNotificationText', { await _channel.invokeMethod<void>('updateNotificationText', {
'title': 'Revolt VPN', 'title': 'Revolt VPN',
'text': text, 'text': text,
'disconnectLabel': 'Disconnect', 'disconnectLabel': 'Disconnect',
}); });
_lastText = text;
} catch (_) { } catch (_) {
// Cosmetic. // Cosmetic.
} }