Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/src/main/java/io/nekohasekai/sagernet/bg/BaseService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
10 changes: 7 additions & 3 deletions app/src/main/java/io/nekohasekai/sagernet/bg/VpnService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) }
Expand Down
69 changes: 36 additions & 33 deletions app/src/main/java/moe/matsuri/nb4a/NativeInterface.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand All @@ -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()
}

Expand Down
124 changes: 124 additions & 0 deletions app/src/test/java/moe/matsuri/nb4a/IfaceReportStateTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}