fix: make network monitor less strict for network capabilities

Network monitor was too strict with capability checks, impacting our DNS resolver which needs to bind to underlying network. Capabilities have been separated out into a separate state property so we always pass the active network to connectivity state for system dns

Improve system dns by supporting DnsResolver on modern devices

#1270
This commit is contained in:
zaneschepke
2026-06-28 13:41:08 -04:00
parent 5f89b2ed31
commit fbd470f5d2
4 changed files with 116 additions and 45 deletions
@@ -162,6 +162,7 @@ class AndroidNetworkMonitor(
if (intent.action == Intent.ACTION_AIRPLANE_MODE_CHANGED) {
val isOn = intent.getBooleanExtra("state", false)
Timber.d("Airplane mode changed: $isOn")
if (isOn) activeCellularNetworks.value = emptyMap()
airplaneModeState.update { isOn }
}
}
@@ -530,7 +531,7 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") }
}
private fun hasGoodNetworkCaps(caps: NetworkCapabilities?): Boolean {
private fun hasValidatedInternet(caps: NetworkCapabilities?): Boolean {
if (caps == null) return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) &&
@@ -538,19 +539,6 @@ class AndroidNetworkMonitor(
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
}
private fun hasGoodCellularNetwork(): Boolean =
activeCellularNetworks.value.values.any { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) && hasGoodNetworkCaps(caps)
}
private fun getGoodCellularNetwork(): Network? =
activeCellularNetworks.value.entries
.firstOrNull { (_, caps) ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
hasGoodNetworkCaps(caps)
}
?.key
// default network events don't contain detailed capability information of underlying networks,
// so we need to track separately
private data class NetworkData(
@@ -570,6 +558,25 @@ class AndroidNetworkMonitor(
NetworkData(defaultEvent, wifiEvent, cellularEvent, ethernetEvent)
}
// For multi-sim selection, prefers foreground, then validated internet, then not suspended
private fun pickBestCellularNetwork(): Network? {
if (activeCellularNetworks.value.isEmpty()) return null
return activeCellularNetworks.value.entries
.maxByOrNull { (_, caps) ->
var score = 0
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND)) score += 100
if (hasValidatedInternet(caps)) score += 50
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_SUSPENDED))
score += 20
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_CONGESTED))
score += 10
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) score += 5
score
}
?.key
}
@OptIn(FlowPreview::class)
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(
@@ -605,6 +612,7 @@ class AndroidNetworkMonitor(
activeNetwork = ActiveNetwork.Disconnected(),
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
airplaneModeOn = isAirplaneOn,
vpnState = VpnState.Inactive,
)
}
@@ -626,23 +634,15 @@ class AndroidNetworkMonitor(
val physicalNetwork: ActiveNetwork =
when {
networkData.ethernetEvent is TransportEvent.CapabilitiesChanged &&
networkData.ethernetEvent.networkCapabilities?.let { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} == true -> {
networkData.ethernetEvent.networkCapabilities?.hasTransport(
NetworkCapabilities.TRANSPORT_ETHERNET
) == true -> {
ActiveNetwork.Ethernet(networkData.ethernetEvent.network)
}
networkData.wifiNetworkEvent is TransportEvent.CapabilitiesChanged &&
networkData.wifiNetworkEvent.networkCapabilities?.let { caps ->
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
caps.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
} == true -> {
val wifiEvent = networkData.wifiNetworkEvent
@@ -689,18 +689,12 @@ class AndroidNetworkMonitor(
)
}
else -> {
val defaultIsGoodCellular =
!isAirplaneOn &&
defaultCaps?.hasTransport(
NetworkCapabilities.TRANSPORT_CELLULAR
) == true &&
hasGoodNetworkCaps(defaultCaps)
val cellularNetwork =
pickBestCellularNetwork()
?: activeCellularNetworks.value.keys.firstOrNull()
val cellularNet =
getGoodCellularNetwork()
?: if (defaultIsGoodCellular) defaultNetwork else null
if (cellularNet != null) {
ActiveNetwork.Cellular(cellularNet)
if (cellularNetwork != null) {
ActiveNetwork.Cellular(cellularNetwork)
} else {
ActiveNetwork.Disconnected()
}
@@ -730,14 +724,31 @@ class AndroidNetworkMonitor(
privateDnsHostname = privateDnsSettings.hostname,
)
val physicalCaps: NetworkCapabilities? =
when (physicalNetwork) {
is ActiveNetwork.Wifi ->
(networkData.wifiNetworkEvent as? TransportEvent.CapabilitiesChanged)
?.networkCapabilities
is ActiveNetwork.Cellular ->
activeCellularNetworks.value[physicalNetwork.network]
is ActiveNetwork.Ethernet ->
(networkData.ethernetEvent as? TransportEvent.CapabilitiesChanged)
?.networkCapabilities
else -> null
}
val hasValidatedInternet = hasValidatedInternet(physicalCaps)
ConnectivityState(
activeNetwork = physicalNetwork,
locationPermissionsGranted = permissions.locationPermissionGranted,
locationServicesEnabled = permissions.locationServicesEnabled,
vpnState = vpnState,
airplaneModeOn = isAirplaneOn,
effectiveDnsInfo = effectiveDns,
underlyingDnsInfo = underlyingDns,
hasIpv6 = hasIpv6Support(underlyingNetwork, physicalNetwork),
hasValidatedInternet = hasValidatedInternet,
)
}
.distinctUntilChanged()
@@ -11,8 +11,11 @@ data class ConnectivityState(
val effectiveDnsInfo: DnsInfo = DnsInfo(),
val underlyingDnsInfo: DnsInfo = DnsInfo(),
val hasIpv6: Boolean = false,
val airplaneModeOn: Boolean = false,
val hasValidatedInternet: Boolean = false,
) {
fun hasInternet(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
fun hasActiveNetwork(): Boolean = activeNetwork !is ActiveNetwork.Disconnected
override fun toString(): String {
val networkInfo =
@@ -440,7 +440,7 @@ class TunnelBackend(
while (isActive) {
val stable = stableNetworkEngine.stableState.value
if (stable?.state?.hasInternet() == true) {
if (stable?.state?.hasActiveNetwork() == true) {
val tunnel = _status.value.activeTunnels[tunnelId] ?: continue
tunnel.mode?.let { mode ->
reconcilePeers(tunnelId, handle, mode, PeerUpdateReason.DDNS_CHECK)
@@ -1,29 +1,86 @@
package com.zaneschepke.tunnel.backend.dns
import android.content.Context
import android.net.DnsResolver
import android.net.Network
import android.os.Build
import android.os.CancellationSignal
import androidx.annotation.RequiresApi
import com.zaneschepke.tunnel.model.DnsBootstrapResult
import java.net.UnknownHostException
import java.net.InetAddress
import java.util.concurrent.Executor
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import timber.log.Timber
internal class AndroidNetworkResolver(private val network: Network) : PeerResolver {
internal class AndroidNetworkResolver(private val network: Network) : PeerResolver, KoinComponent {
private val context: Context by inject()
@Suppress("NewApi")
private val dnsResolver: DnsResolver by lazy {
if (Build.VERSION.SDK_INT >= 37) {
DnsResolver(context, null)
} else {
@Suppress("DEPRECATION") DnsResolver.getInstance()
}
}
override suspend fun resolve(host: String): DnsBootstrapResult =
withContext(Dispatchers.IO) {
try {
// use underlying network for resolution
val ips = network.getAllByName(host)
val ips =
withTimeoutOrNull(2_200L.milliseconds) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolveAsync(host)
} else {
network.getAllByName(host).toList()
}
}
?: run {
Timber.w("DNS resolution timed out after 2200ms for $host")
return@withContext DnsBootstrapResult()
}
Timber.d("Resolution from network bind socket: ${ips.contentToString()}")
Timber.d("Resolution from network bind socket: $ips")
val v4 = ips.filter { it.address.size == 4 }.map { it.hostAddress }
val v6 = ips.filter { it.address.size == 16 }.map { it.hostAddress }
DnsBootstrapResult(v4, v6)
} catch (e: UnknownHostException) {
} catch (e: Exception) {
Timber.e(e, "System DNS failed to resolve host")
DnsBootstrapResult()
}
}
@RequiresApi(Build.VERSION_CODES.Q)
private suspend fun resolveAsync(host: String): List<InetAddress> =
suspendCancellableCoroutine { continuation ->
val signal = CancellationSignal()
continuation.invokeOnCancellation { signal.cancel() }
dnsResolver.query(
network,
host,
DnsResolver.FLAG_EMPTY,
Executor { it.run() },
signal,
object : DnsResolver.Callback<List<InetAddress>> {
override fun onAnswer(answer: List<InetAddress>, rcode: Int) {
continuation.resume(answer)
}
override fun onError(error: DnsResolver.DnsException) {
continuation.resumeWithException(error)
}
},
)
}
}