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:
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user