Fix tunnel hang on LTE -> WiFi handover (#5977)

* fix: restart the core on network change in all run modes

* Update CoreServiceManager.kt

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
This commit is contained in:
vemneyyand2dust authored and GitHub committed 2026-08-01 14:20:23 +08:00
1 parent 17b6254d4d
commit 8b81fdf383
4 files changed
+234 -64

No files matched your search

@@ -1,6 +1,7 @@
package com.v2ray.ang.contracts package com.v2ray.ang.contracts
import android.app.Service import android.app.Service
import android.net.Network
interface ServiceControl { interface ServiceControl {
/** /**
@@ -25,4 +26,13 @@ interface ServiceControl {
* @return True if the socket is protected, false otherwise. * @return True if the socket is protected, false otherwise.
*/ */
fun vpnProtect(socket: Int): Boolean fun vpnProtect(socket: Int): Boolean
/**
* Declares the networks the tunnel runs on top of.
* Only meaningful for the VPN service, the other run modes have no interface to report.
*
* @param networks The upstream networks, null to let the system pick.
* @return True if the networks were accepted.
*/
fun setUnderlyingNetworks(networks: Array<Network>?): Boolean = false
} }
@@ -25,11 +25,13 @@ import com.v2ray.ang.handler.SpeedtestManager
import com.v2ray.ang.helper.MessageHelper import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.service.DialerNativeService import com.v2ray.ang.service.DialerNativeService
import com.v2ray.ang.service.DialerWebviewService import com.v2ray.ang.service.DialerWebviewService
import com.v2ray.ang.service.NetworkMonitor
import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.jvm.Volatile
import libv2ray.CoreCallbackHandler import libv2ray.CoreCallbackHandler
import libv2ray.CoreController import libv2ray.CoreController
import libv2ray.ProcessFinder import libv2ray.ProcessFinder
@@ -43,6 +45,13 @@ object CoreServiceManager {
private var currentConfig: ProfileItem? = null private var currentConfig: ProfileItem? = null
private var processFinder: XrayProcessFinder? = null private var processFinder: XrayProcessFinder? = null
private var browserDialer: IDialerService? = null private var browserDialer: IDialerService? = null
private var networkMonitor: NetworkMonitor? = null
@Volatile
private var isReloading = false
/** Tun descriptor the core was started with, null in the proxy only and root run modes. */
private var currentVpnInterface: ParcelFileDescriptor? = null
var serviceControl: SoftReference<ServiceControl>? = null var serviceControl: SoftReference<ServiceControl>? = null
set(value) { set(value) {
@@ -98,6 +107,19 @@ object CoreServiceManager {
@Throws(Exception::class) @Throws(Exception::class)
private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) { private fun doStartCoreLoop(service: Service, vpnInterface: ParcelFileDescriptor?) {
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
currentVpnInterface = vpnInterface
launchCore(service, vpnInterface)
startNetworkMonitor(service)
}
@Throws(Exception::class)
private fun launchCore(service: Service, vpnInterface: ParcelFileDescriptor?, isReload: Boolean = false) {
val guid = MmkvManager.getSelectServer() ?: error("No server selected") val guid = MmkvManager.getSelectServer() ?: error("No server selected")
val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config") val config = MmkvManager.decodeServerConfig(guid) ?: error("Failed to decode server config")
@@ -108,12 +130,6 @@ object CoreServiceManager {
error(result.errorMessage.ifBlank { "Failed to get V2Ray config" }) error(result.errorMessage.ifBlank { "Failed to get V2Ray config" })
} }
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
mFilter.addAction(Intent.ACTION_SCREEN_ON)
mFilter.addAction(Intent.ACTION_SCREEN_OFF)
mFilter.addAction(Intent.ACTION_USER_PRESENT)
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
currentConfig = config currentConfig = config
var tunFd = vpnInterface?.fd ?: 0 var tunFd = vpnInterface?.fd ?: 0
val dialerMode = BrowserDialerMode.from(config.browserDialerMode) val dialerMode = BrowserDialerMode.from(config.browserDialerMode)
@@ -154,7 +170,9 @@ object CoreServiceManager {
else -> {} else -> {}
} }
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "") if (!isReload) {
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
}
NotificationManager.startSpeedNotification() NotificationManager.startSpeedNotification()
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully") LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
} }
@@ -167,6 +185,10 @@ object CoreServiceManager {
fun stopCoreLoop(): Boolean { fun stopCoreLoop(): Boolean {
val service = getService() ?: return false val service = getService() ?: return false
networkMonitor?.unregister()
networkMonitor = null
currentVpnInterface = null
if (isRunning()) { if (isRunning()) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
@@ -196,11 +218,83 @@ object CoreServiceManager {
return true return true
} }
/**
* Subscribes to upstream network changes for whichever run mode is active.
* All three services share this manager, so the tunnel recovers from a handover in proxy only
* and root mode as well, not just behind the VPN interface.
*/
private fun startNetworkMonitor(service: Service) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return
if (networkMonitor != null) return
val connectivity = service.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
networkMonitor = NetworkMonitor(
connectivity = connectivity,
onUnderlyingNetworksChanged = { networks -> serviceControl?.get()?.setUnderlyingNetworks(networks) },
onHandover = { reloadCore() },
).also { it.register() }
}
/**
* Restarts the core in place after the upstream network changed: the service, the notification
* and the VPN interface all stay up, so nothing of this is visible.
*
* The config is rebuilt on purpose, outbound server domains are resolved while building it and
* an address resolved on a network that is gone can be unusable on the new one.
*
* @return True if the core is running again.
*/
private fun reloadCore(): Boolean {
if (isReloading) return false
val service = getService() ?: return false
if (!isRunning()) return false
return try {
val tunFd = tunFdForCore()
isReloading = true
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload start...")
coreController.stopLoop()
launchCore(service, tunFd, isReload = true)
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Core reload finished")
true
} catch (e: Exception) {
val message = e.message?.takeUnless { it.isBlank() } ?: e.javaClass.simpleName
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to reload core: $message", e)
MessageHelper.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, message)
false
} finally {
isReloading = false
}
}
/**
* Returns the tun descriptor to hand to the core on a reload.
*
* With hev-socks5-tunnel the core is started without a tun and never touches the descriptor.
* Otherwise it closes the one it was given when it stops, which would take the VPN interface
* down, so it gets a duplicate to close instead.
*/
private fun tunFdForCore(): ParcelFileDescriptor? {
val vpnInterface = currentVpnInterface ?: return null
return try {
if (SettingsManager.isUsingHevTun()) vpnInterface else vpnInterface.dup()
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to duplicate VPN interface", e)
throw e
}
}
/** /**
* Queries and resets all outbound traffic counters in one core call. * Queries and resets all outbound traffic counters in one core call.
* Go side format: tag,direction,value;tag,direction,value; * Go side format: tag,direction,value;tag,direction,value;
*/ */
fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> { fun queryAllOutboundTrafficStats(): List<OutboundTrafficStat> {
// The stats manager is gone once the core stops, querying it then reaches into freed state.
if (!isRunning()) return emptyList()
val payload = coreController.queryAllOutboundTrafficStats() val payload = coreController.queryAllOutboundTrafficStats()
val result = ArrayList<OutboundTrafficStat>() val result = ArrayList<OutboundTrafficStat>()
@@ -5,16 +5,12 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.ConnectivityManager
import android.net.Network import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.net.ProxyInfo import android.net.ProxyInfo
import android.net.VpnService import android.net.VpnService
import android.os.Build import android.os.Build
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.os.StrictMode import android.os.StrictMode
import androidx.annotation.RequiresApi
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.LOOPBACK import com.v2ray.ang.AppConfig.LOOPBACK
import com.v2ray.ang.BuildConfig import com.v2ray.ang.BuildConfig
@@ -38,43 +34,6 @@ class CoreVpnService : VpnService(), ServiceControl {
private var tun2SocksService: Tun2SocksControl? = null private var tun2SocksService: Tun2SocksControl? = null
private val isStartingLock = AtomicBoolean(false) private val isStartingLock = AtomicBoolean(false)
/**destroy
* Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
*
* This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
* satisfies default network capabilities but only THE default network. Unfortunately we need to have
* android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
*
* Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
*/
@delegate:RequiresApi(Build.VERSION_CODES.P)
private val defaultNetworkRequest by lazy {
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
.build()
}
private val connectivity by lazy { getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager }
@delegate:RequiresApi(Build.VERSION_CODES.P)
private val defaultNetworkCallback by lazy {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
setUnderlyingNetworks(arrayOf(network))
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
// it's a good idea to refresh capabilities
setUnderlyingNetworks(arrayOf(network))
}
override fun onLost(network: Network) {
setUnderlyingNetworks(null)
}
}
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created") LogUtil.i(AppConfig.TAG, "StartCore-VPN: Service created")
@@ -165,6 +124,10 @@ class CoreVpnService : VpnService(), ServiceControl {
return protect(socket) return protect(socket)
} }
override fun setUnderlyingNetworks(networks: Array<Network>?): Boolean {
return super<VpnService>.setUnderlyingNetworks(networks)
}
override fun attachBaseContext(newBase: Context?) { override fun attachBaseContext(newBase: Context?) {
val context = newBase?.let { val context = newBase?.let {
MyContextWrapper.wrap(newBase, SettingsManager.getLocale()) MyContextWrapper.wrap(newBase, SettingsManager.getLocale())
@@ -283,15 +246,6 @@ class CoreVpnService : VpnService(), ServiceControl {
* @param builder The VPN Builder to configure * @param builder The VPN Builder to configure
*/ */
private fun configurePlatformFeatures(builder: Builder) { private fun configurePlatformFeatures(builder: Builder) {
// Android P (API 28) and above: Configure network callbacks
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
}
}
// Android Q (API 29) and above: Configure metering and HTTP proxy // Android Q (API 29) and above: Configure metering and HTTP proxy
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false) builder.setMetered(false)
@@ -372,13 +326,6 @@ class CoreVpnService : VpnService(), ServiceControl {
// saveVpnNetworkInfo(configName, info) // saveVpnNetworkInfo(configName, info)
unlockStart() unlockStart()
isRunning = false isRunning = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
try {
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
}
}
tun2SocksService?.stopTun2Socks() tun2SocksService?.stopTun2Socks()
tun2SocksService = null tun2SocksService = null
@@ -0,0 +1,119 @@
package com.v2ray.ang.service
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.v2ray.ang.AppConfig
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Watches the network that carries the tunnel and reports topology changes.
*
* Cellular -> Wi-Fi is a make-before-break handover: the new network is announced while the old one
* is still connected, so the socket to the server is never reset and the core keeps using a dead
* connection. Deciding that a handover happened is what this class is for, acting on it is not.
*
* Only used from Android P and above, see CoreServiceManager.startNetworkMonitor().
* [onHandover] is invoked on a background thread after the debounce window and may block.
*/
class NetworkMonitor(
private val connectivity: ConnectivityManager,
private val onUnderlyingNetworksChanged: (Array<Network>?) -> Unit,
private val onHandover: () -> Unit,
) {
private companion object {
const val HANDOVER_DEBOUNCE_MS = 1000L
}
private var upstream: Network? = null
private var handoverJob: Job? = null
private var registered = false
/**
* Unfortunately registerDefaultNetworkCallback is going to return our VPN interface:
* https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
*
* This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that
* satisfies default network capabilities but only THE default network. Unfortunately we need to have
* android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork.
*
* Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887
*/
private val request by lazy {
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
.build()
}
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
val previous = upstream
upstream = network
onUnderlyingNetworksChanged(arrayOf(network))
if (previous != null && previous != network) {
scheduleHandover(network)
}
}
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
// it's a good idea to refresh capabilities
onUnderlyingNetworksChanged(arrayOf(network))
}
override fun onLost(network: Network) {
onUnderlyingNetworksChanged(null)
}
}
/**
* Starts watching. Safe to call more than once, only the first call registers.
*/
fun register() {
if (registered) return
try {
connectivity.requestNetwork(request, callback)
registered = true
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "NetworkMonitor: Failed to request network", e)
}
}
/**
* Stops watching and drops the tracked state. Safe to call more than once.
*/
fun unregister() {
handoverJob?.cancel()
handoverJob = null
upstream = null
if (!registered) return
registered = false
try {
connectivity.unregisterNetworkCallback(callback)
} catch (e: Exception) {
LogUtil.w(AppConfig.TAG, "NetworkMonitor: Failed to unregister callback", e)
}
}
private fun scheduleHandover(network: Network) {
LogUtil.i(AppConfig.TAG, "NetworkMonitor: Upstream is now $network")
handoverJob?.cancel()
handoverJob = CoroutineScope(Dispatchers.IO).launch {
try {
delay(HANDOVER_DEBOUNCE_MS)
onHandover()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "NetworkMonitor: Failed to handle upstream change", e)
}
}
}
}