diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt index b992acaf..8f5a7906 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt @@ -540,10 +540,13 @@ class BaseService { // Tracks the underlying network for VpnService.setUnderlyingNetworks only; the // "reset on network change" switch is applied by NativeInterface's monitor callback. DefaultNetworkListener.start(this) { network -> - if (network == null) return@start + SagerNet.underlyingNetwork = network + DataStore.vpnService?.updateUnderlyingNetwork() + if (network == null) { + upstreamInterfaceName = null + return@start + } SagerNet.connectivity.getLinkProperties(network)?.also { link -> - SagerNet.underlyingNetwork = network - DataStore.vpnService?.updateUnderlyingNetwork() val oldName = upstreamInterfaceName if (oldName != link.interfaceName) { Logs.d("Network changed: $oldName -> ${link.interfaceName}") diff --git a/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt index c0e7075b..6a2448ac 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt @@ -201,9 +201,13 @@ class VpnService : BaseVpnService(), } fun updateUnderlyingNetwork(builder: Builder? = null) { - SagerNet.underlyingNetwork?.let { - builder?.setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork)) - ?: setUnderlyingNetworks(arrayOf(SagerNet.underlyingNetwork)) + val networks = SagerNet.underlyingNetwork?.let { arrayOf(it) } + if (builder != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + builder.setUnderlyingNetworks(networks) + } + } else { + setUnderlyingNetworks(networks) } } diff --git a/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt b/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt index a9791380..60b9c884 100644 --- a/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt +++ b/app/src/main/java/io/nekohasekai/sagernet/utils/DefaultNetworkListener.kt @@ -2,6 +2,7 @@ package io.nekohasekai.sagernet.utils import android.annotation.TargetApi import android.net.ConnectivityManager +import android.net.LinkProperties import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest @@ -129,6 +130,13 @@ object DefaultNetworkListener { runBlocking { networkActor.send(NetworkMessage.Update(network)) } } + override fun onLinkPropertiesChanged( + network: Network, linkProperties: LinkProperties + ) { + Logs.d("DefaultNetworkListener onLinkPropertiesChanged enter network=$network thread=${Thread.currentThread().name}") + runBlocking { networkActor.send(NetworkMessage.Update(network)) } + } + override fun onLost(network: Network) { Logs.d("DefaultNetworkListener onLost enter network=$network thread=${Thread.currentThread().name}") runBlocking { networkActor.send(NetworkMessage.Lost(network)) } diff --git a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt index e99fc22e..67abfc35 100644 --- a/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt +++ b/app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt @@ -8,6 +8,7 @@ import android.net.Network import android.net.NetworkCapabilities import android.os.Build import android.os.Process +import android.os.SystemClock import android.system.OsConstants import androidx.core.app.NotificationCompat import androidx.core.net.toUri @@ -112,6 +113,7 @@ class NativeInterface : PlatformInterface { var index: Int = Int.MIN_VALUE var network: Network? = null var isExpensive: Boolean = false + var hasReportedAtLeastOnce: Boolean = false } private val ifaceReportStates = Collections.synchronizedMap( @@ -120,6 +122,8 @@ class NativeInterface : PlatformInterface { @Volatile private var lastResetNetwork: Network? = null + @Volatile + private var lastResetTime: Long = 0L private fun reportState(listener: InterfaceUpdateListener): IfaceReportState = synchronized(ifaceReportStates) { @@ -140,48 +144,47 @@ class NativeInterface : PlatformInterface { clearInterface(listener, state) return } - // LinkProperties / NetworkInterface may lag behind the callback briefly. - repeat(10) { - val linkProperties = SagerNet.connectivity.getLinkProperties(network) - if (linkProperties == null) { - Thread.sleep(100) - return@repeat - } - val interfaceIndex = try { - NetworkInterface.getByName(linkProperties.interfaceName).index - } catch (e: Exception) { - Thread.sleep(100) - return@repeat - } - val capabilities = SagerNet.connectivity.getNetworkCapabilities(network) - val isExpensive = capabilities?.let { - it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || - !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) - } ?: false - val sameInterface = state.name == linkProperties.interfaceName && state.index == interfaceIndex && - state.network == network - // Capability-change storms repeat the same interface; skip them without a JNI round trip. A metering change - // alone (a Wi-Fi marked metered) is still reported so the core re-reads its interfaces, but is no switch. - if (sameInterface && state.isExpensive == isExpensive) return - val changed = !sameInterface && state.name != null - state.name = linkProperties.interfaceName - state.index = interfaceIndex - state.network = network - state.isExpensive = isExpensive - listener.updateDefaultInterface(linkProperties.interfaceName, interfaceIndex, isExpensive, false) - if (changed) onDefaultInterfaceChanged(network) + val linkProperties = SagerNet.connectivity.getLinkProperties(network) + val ifaceName = linkProperties?.interfaceName + val networkInterface = try { + if (!ifaceName.isNullOrEmpty()) NetworkInterface.getByName(ifaceName) else null + } catch (_: Exception) { + null + } + val interfaceIndex = networkInterface?.index ?: -1 + if (linkProperties == null || networkInterface == null || interfaceIndex == -1 || ifaceName.isNullOrEmpty()) { + // LinkProperties or NetworkInterface is not yet provisioned by the OS. + // Do not block the callback thread or clearInterface; onLinkPropertiesChanged will trigger an update. return } - Logs.w("checkDefaultInterfaceUpdate exhausted retries for $network") - clearInterface(listener, state) + val capabilities = SagerNet.connectivity.getNetworkCapabilities(network) + val isExpensive = capabilities?.let { + it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || + !it.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + } ?: false + val sameInterface = state.name == ifaceName && state.index == interfaceIndex && + state.network == network + // Capability-change storms repeat the same interface; skip them without a JNI round trip. A metering change + // alone (a Wi-Fi marked metered) is still reported so the core re-reads its interfaces, but is no switch. + if (sameInterface && state.isExpensive == isExpensive) return + val changed = !sameInterface && (state.name != null || state.hasReportedAtLeastOnce) + state.name = ifaceName + state.index = interfaceIndex + state.network = network + state.isExpensive = isExpensive + state.hasReportedAtLeastOnce = true + listener.updateDefaultInterface(ifaceName, interfaceIndex, isExpensive, false) + if (changed) onDefaultInterfaceChanged(network) } // Every box in this process has its own listener, so the user's "reset on network change" // switch is applied once per new network to the running VPN instance only. private fun onDefaultInterfaceChanged(network: Network) { if (!DataStore.networkChangeResetConnections) return - if (lastResetNetwork == network) return + val now = SystemClock.elapsedRealtime() + if (lastResetNetwork == network && now - lastResetTime < 1500L) return lastResetNetwork = network + lastResetTime = now DataStore.baseService?.data?.proxy?.boxOrNull?.resetNetwork() } diff --git a/app/src/test/java/moe/matsuri/nb4a/IfaceReportStateTest.kt b/app/src/test/java/moe/matsuri/nb4a/IfaceReportStateTest.kt new file mode 100644 index 00000000..a95e6fe2 --- /dev/null +++ b/app/src/test/java/moe/matsuri/nb4a/IfaceReportStateTest.kt @@ -0,0 +1,124 @@ +package moe.matsuri.nb4a + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class IfaceReportStateTest { + + private class IfaceReportState { + var name: String? = null + var index: Int = Int.MIN_VALUE + var networkId: Int? = null + var isExpensive: Boolean = false + var hasReportedAtLeastOnce: Boolean = false + } + + private data class UpdateResult( + val reportedName: String, + val reportedIndex: Int, + val isExpensive: Boolean, + val changed: Boolean, + ) + + private fun simulateUpdate( + state: IfaceReportState, + ifaceName: String?, + interfaceIndex: Int, + networkId: Int?, + isExpensive: Boolean, + ): UpdateResult? { + if (networkId == null || ifaceName == null) { + // clearInterface + state.name = null + state.index = Int.MIN_VALUE + state.networkId = null + state.isExpensive = false + return UpdateResult("", -1, false, false) + } + + val sameInterface = state.name == ifaceName && state.index == interfaceIndex && + state.networkId == networkId + + if (sameInterface && state.isExpensive == isExpensive) { + return null // short-circuited duplicate + } + + val changed = !sameInterface && (state.name != null || state.hasReportedAtLeastOnce) + state.name = ifaceName + state.index = interfaceIndex + state.networkId = networkId + state.isExpensive = isExpensive + state.hasReportedAtLeastOnce = true + + return UpdateResult(ifaceName, interfaceIndex, isExpensive, changed) + } + + @Test + fun initialNetworkDetectionDoesNotTriggerReset() { + val state = IfaceReportState() + val result = simulateUpdate(state, "wlan0", 12, 100, isExpensive = false) + + // Initial connection must not be considered a handover/switch + assertTrue(result != null) + assertFalse("First interface report must not trigger connection reset", result!!.changed) + assertTrue(state.hasReportedAtLeastOnce) + } + + @Test + fun handoverWithDisconnectTriggerResetOnNewInterface() { + val state = IfaceReportState() + // 1. Initially on Wi-Fi + simulateUpdate(state, "wlan0", 12, 100, isExpensive = false) + + // 2. Wi-Fi drops (Break-Before-Make) + val lostResult = simulateUpdate(state, null, -1, null, isExpensive = false) + assertTrue(lostResult != null) + assertFalse(lostResult!!.changed) + + // 3. Mobile data connects + val mobileResult = simulateUpdate(state, "rmnet0", 15, 101, isExpensive = true) + assertTrue(mobileResult != null) + assertTrue( + "Switching from disconnected state to mobile data must trigger connection reset", + mobileResult!!.changed, + ) + } + + @Test + fun handoverDirectSwitchTriggersReset() { + val state = IfaceReportState() + // 1. Wi-Fi + simulateUpdate(state, "wlan0", 12, 100, isExpensive = false) + + // 2. Direct handover to LTE (make-before-break) + val lteResult = simulateUpdate(state, "rmnet0", 15, 101, isExpensive = true) + assertTrue(lteResult != null) + assertTrue("Direct handover must trigger connection reset", lteResult!!.changed) + } + + @Test + fun duplicateCapabilityEventsDoNotTriggerReset() { + val state = IfaceReportState() + simulateUpdate(state, "wlan0", 12, 100, isExpensive = false) + + // Same interface, same network, same capabilities + val duplicateResult = simulateUpdate(state, "wlan0", 12, 100, isExpensive = false) + assertTrue("Duplicate capability event should be skipped", duplicateResult == null) + } + + @Test + fun reconnectingToSameInterfaceAfterDisconnectTriggersReset() { + val state = IfaceReportState() + // 1. Wi-Fi + simulateUpdate(state, "wlan0", 12, 100, isExpensive = false) + + // 2. Temporary disconnect + simulateUpdate(state, null, -1, null, false) + + // 3. Reconnects to Wi-Fi with new Network instance + val reconnectResult = simulateUpdate(state, "wlan0", 12, 102, isExpensive = false) + assertTrue(reconnectResult != null) + assertTrue("Reconnecting after drop should reset stale connections", reconnectResult!!.changed) + } +}