feat(app): keep the tunnel alive via the OS, drop the workarounds
3.3.1 could display "connected" against a tunnel that was no longer running, with a frozen countdown and no traffic. Three causes, and the fix for each removes more code than it adds. Liveness was read from getConnectedServerDelay(), which measures through AppConfigs.V2RAY_CONFIG?.LOCAL_SOCKS5_PORT ?: 10807. AppConfigs lives in the :RunSoLibXrayDaemon process, so from the UI process it is always null and the port always fell back to 10807 -- accidentally correct while the SOCKS port was fixed, permanently wrong once 3.3.1 made it ephemeral. Liveness now comes from the OS: is our service process running, and is a VPN transport actually present. The foreground notification (id 1) is owned by XrayVPNService via startForeground, but MainActivity was posting to the same id from the UI process. When that process died its notification stayed on screen with nothing left to update it. MainActivity now refuses to post for a dead runtime and clears the stale notification instead. Nothing ever asked Android for permission to stay alive. Adds the battery-optimisation exemption prompt and a deep link to Always-on VPN -- the two sanctioned mechanisms for background survival, each needing one user tap. Removes Standard/Extreme resilience and the runtime-restart machinery: recovery ran off a disconnect broadcast delivered to the app process, so it could never fire when Android killed that process. Removes startup restoration and the config-snapshot rebuild along with it; with the OS keeping the tunnel up there is nothing to restore. Version 3.3.2+28. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
ac52681452
commit
186dc906da
12 files changed
+422
-291
No files matched your search
@@ -7,6 +7,9 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<!-- Doze/App Standby stop background services of non-exempt apps. A VPN
|
||||
cannot stay up without this; Play allows it for VPN functionality. -->
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||
|
||||
<!-- flutter_vless 1.1.5 declares this legacy permission. ReVolt never needs it. -->
|
||||
<uses-permission
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.paladinvpn.app
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.ActivityManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -11,10 +12,13 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.github.tfox.flutter_vless.xray.service.XrayVPNService
|
||||
@@ -29,6 +33,7 @@ class MainActivity : FlutterActivity() {
|
||||
private val notificationChannel = "com.revoltvpn.app/notification"
|
||||
private val hapticsChannel = "com.revoltvpn.app/haptics"
|
||||
private val networkChannel = "com.revoltvpn.app/network"
|
||||
private val powerChannel = "com.revoltvpn.app/power"
|
||||
|
||||
private var notificationChannelReady = false
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
@@ -46,6 +51,28 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, powerChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
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" ->
|
||||
result.success(isIgnoringBatteryOptimizations())
|
||||
"requestIgnoreBatteryOptimizations" ->
|
||||
result.success(requestIgnoreBatteryOptimizations())
|
||||
// Android's own Always-on VPN is the only thing that keeps a
|
||||
// tunnel up across swipe-from-recents and reboot. It cannot be
|
||||
// toggled programmatically, so deep-link the user to it.
|
||||
"openVpnSettings" -> result.success(openVpnSettings())
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, notificationChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "updateNotificationText") {
|
||||
@@ -88,6 +115,98 @@ class MainActivity : FlutterActivity() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
/** True when the OS has exempted us from Doze / App Standby. */
|
||||
private fun isIgnoringBatteryOptimizations(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||
return try {
|
||||
val pm = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
pm.isIgnoringBatteryOptimizations(packageName)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
|
||||
if (isIgnoringBatteryOptimizations()) return true
|
||||
return try {
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
||||
Uri.parse("package:$packageName"),
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
// Some OEM builds hide the direct intent; fall back to the list.
|
||||
try {
|
||||
startActivity(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS))
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep-links to the OS VPN settings, where Always-on VPN lives. */
|
||||
private fun openVpnSettings(): Boolean {
|
||||
return try {
|
||||
startActivity(
|
||||
Intent(Settings.ACTION_VPN_SETTINGS)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
)
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
@@ -96,6 +215,15 @@ class MainActivity : FlutterActivity() {
|
||||
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()
|
||||
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
@@ -344,6 +472,7 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val VPN_PROCESS_SUFFIX = ":RunSoLibXrayDaemon"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "XRAY_SERVICE_CHANNEL"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user