Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Track IPv6 connectivity on Android #7577

Merged
merged 1 commit into from
Mar 7, 2025
Merged
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
3 changes: 3 additions & 0 deletions android/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ Line wrap the file at 100 chars. Th
### Removed
- Remove Google's resolvers from encrypted DNS proxy.

### Fixed
- Will no longer try to connect over IPv6 if IPv6 is not available.


## [android/2024.10-beta2] - 2024-12-20

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import android.net.LinkProperties
import java.net.InetAddress
import kotlin.collections.ArrayList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
Expand All @@ -15,13 +17,22 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.runBlocking
import net.mullvad.talpid.model.Connectivity
import net.mullvad.talpid.model.NetworkState
import net.mullvad.talpid.util.RawNetworkState
import net.mullvad.talpid.util.UnderlyingConnectivityStatusResolver
import net.mullvad.talpid.util.activeRawNetworkState
import net.mullvad.talpid.util.defaultRawNetworkStateFlow
import net.mullvad.talpid.util.hasInternetConnectivity
import net.mullvad.talpid.util.resolveConnectivityStatus

class ConnectivityListener(private val connectivityManager: ConnectivityManager) {
private lateinit var _isConnected: StateFlow<Boolean>
class ConnectivityListener(
private val connectivityManager: ConnectivityManager,
private val resolver: UnderlyingConnectivityStatusResolver,
) {
private lateinit var _isConnected: StateFlow<Connectivity>
// Used by JNI
val isConnected
get() = _isConnected.value
Expand All @@ -37,6 +48,7 @@ class ConnectivityListener(private val connectivityManager: ConnectivityManager)
val currentDnsServers: ArrayList<InetAddress>
get() = _mutableNetworkState.value?.dnsServers ?: ArrayList()

@OptIn(FlowPreview::class)
fun register(scope: CoroutineScope) {
// Consider implementing retry logic for the flows below, because registering a listener on
// the default network may fail if the network on Android 11
Expand All @@ -53,12 +65,19 @@ class ConnectivityListener(private val connectivityManager: ConnectivityManager)

_isConnected =
connectivityManager
.hasInternetConnectivity()
.onEach { notifyConnectivityChange(it) }
.hasInternetConnectivity(resolver)
.onEach { notifyConnectivityChange(it.ipv4, it.ipv6) }
.stateIn(
scope,
scope + Dispatchers.IO,
SharingStarted.Eagerly,
true, // Assume we have internet until we know otherwise
// Has to happen on IO to avoid NetworkOnMainThreadException, we actually don't
// send any traffic just open a socket to detect the IP version.
runBlocking(Dispatchers.IO) {
resolveConnectivityStatus(
connectivityManager.activeRawNetworkState(),
resolver,
)
},
)
}

Expand All @@ -80,7 +99,7 @@ class ConnectivityListener(private val connectivityManager: ConnectivityManager)
linkProperties?.dnsServersWithoutFallback(),
)

private external fun notifyConnectivityChange(isConnected: Boolean)
private external fun notifyConnectivityChange(isIPv4: Boolean, isIPv6: Boolean)

private external fun notifyDefaultNetworkChange(networkState: NetworkState?)
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import net.mullvad.talpid.model.CreateTunResult.OtherAlwaysOnApp
import net.mullvad.talpid.model.CreateTunResult.OtherLegacyAlwaysOnVpn
import net.mullvad.talpid.model.TunConfig
import net.mullvad.talpid.util.TalpidSdkUtils.setMeteredIfSupported
import net.mullvad.talpid.util.UnderlyingConnectivityStatusResolver

open class TalpidVpnService : LifecycleVpnService() {
private var activeTunStatus by
Expand All @@ -48,7 +49,11 @@ open class TalpidVpnService : LifecycleVpnService() {
@CallSuper
override fun onCreate() {
super.onCreate()
connectivityListener = ConnectivityListener(getSystemService<ConnectivityManager>()!!)
connectivityListener =
ConnectivityListener(
getSystemService<ConnectivityManager>()!!,
UnderlyingConnectivityStatusResolver(::protect),
)
connectivityListener.register(lifecycleScope)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.mullvad.talpid.model

sealed class Connectivity {
data class Status(val ipv4: Boolean, val ipv6: Boolean) : Connectivity()

// Required by jni
data object PresumeOnline : Connectivity()
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import android.net.ConnectivityManager.NetworkCallback
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import co.touchlab.kermit.Logger
import java.net.Inet4Address
import java.net.Inet6Address
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.channels.awaitClose
Expand All @@ -16,13 +17,12 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.scan
import net.mullvad.talpid.model.Connectivity

private val CONNECTIVITY_DEBOUNCE = 300.milliseconds

internal fun ConnectivityManager.defaultNetworkEvents(): Flow<NetworkEvent> = callbackFlow {
fun ConnectivityManager.defaultNetworkEvents(): Flow<NetworkEvent> = callbackFlow {
val callback =
object : NetworkCallback() {
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
Expand Down Expand Up @@ -68,56 +68,6 @@ internal fun ConnectivityManager.defaultNetworkEvents(): Flow<NetworkEvent> = ca
awaitClose { unregisterNetworkCallback(callback) }
}

fun ConnectivityManager.networkEvents(networkRequest: NetworkRequest): Flow<NetworkEvent> =
callbackFlow {
val callback =
object : NetworkCallback() {
override fun onLinkPropertiesChanged(
network: Network,
linkProperties: LinkProperties,
) {
super.onLinkPropertiesChanged(network, linkProperties)
trySendBlocking(NetworkEvent.LinkPropertiesChanged(network, linkProperties))
}

override fun onAvailable(network: Network) {
super.onAvailable(network)
trySendBlocking(NetworkEvent.Available(network))
}

override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
super.onCapabilitiesChanged(network, networkCapabilities)
trySendBlocking(NetworkEvent.CapabilitiesChanged(network, networkCapabilities))
}

override fun onBlockedStatusChanged(network: Network, blocked: Boolean) {
super.onBlockedStatusChanged(network, blocked)
trySendBlocking(NetworkEvent.BlockedStatusChanged(network, blocked))
}

override fun onLosing(network: Network, maxMsToLive: Int) {
super.onLosing(network, maxMsToLive)
trySendBlocking(NetworkEvent.Losing(network, maxMsToLive))
}

override fun onLost(network: Network) {
super.onLost(network)
trySendBlocking(NetworkEvent.Lost(network))
}

override fun onUnavailable() {
super.onUnavailable()
trySendBlocking(NetworkEvent.Unavailable)
}
}
registerNetworkCallback(networkRequest, callback)

awaitClose { unregisterNetworkCallback(callback) }
}

internal fun ConnectivityManager.defaultRawNetworkStateFlow(): Flow<RawNetworkState?> =
defaultNetworkEvents().scan(null as RawNetworkState?) { state, event -> state.reduce(event) }

Expand Down Expand Up @@ -153,74 +103,65 @@ sealed interface NetworkEvent {
data class Lost(val network: Network) : NetworkEvent
}

internal data class RawNetworkState(
data class RawNetworkState(
val network: Network,
val linkProperties: LinkProperties? = null,
val networkCapabilities: NetworkCapabilities? = null,
val blockedStatus: Boolean = false,
val maxMsToLive: Int? = null,
)

private val nonVPNInternetNetworksRequest =
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()

private sealed interface InternalConnectivityEvent {
data class Available(val network: Network) : InternalConnectivityEvent

data class Lost(val network: Network) : InternalConnectivityEvent
}
internal fun ConnectivityManager.activeRawNetworkState(): RawNetworkState? =
try {
activeNetwork?.let { currentNetwork: Network ->
RawNetworkState(
network = currentNetwork,
linkProperties = getLinkProperties(currentNetwork),
networkCapabilities = getNetworkCapabilities(currentNetwork),
)
}
} catch (_: RuntimeException) {
Logger.e(
"Unable to get active network or properties and capabilities of the active network"
)
null
}

/**
* Return a flow notifying us if we have internet connectivity. Initial state will be taken from
* `allNetworks` and then updated when network events occur. Important to note that `allNetworks`
* may return a network that we never get updates from if turned off at the moment of the initial
* query.
* Return a flow with the current internet connectivity status. The status is based on current
* default network and depending on if it is a VPN. If it is not a VPN we check the network
* properties directly and if it is a VPN we use a socket to check the underlying network. A
* debounce is applied to avoid emitting too many events and to avoid setting the app in an offline
* state when switching networks.
*/
@OptIn(FlowPreview::class)
fun ConnectivityManager.hasInternetConnectivity(): Flow<Boolean> =
networkEvents(nonVPNInternetNetworksRequest)
.mapNotNull {
when (it) {
is NetworkEvent.Available -> InternalConnectivityEvent.Available(it.network)
is NetworkEvent.Lost -> InternalConnectivityEvent.Lost(it.network)
else -> null
}
}
.scan(emptySet<Network>()) { networks, event ->
when (event) {
is InternalConnectivityEvent.Lost -> networks - event.network
is InternalConnectivityEvent.Available -> networks + event.network
}.also { Logger.d("Networks: $it") }
}
// NetworkEvents are slow, can several 100 millis to arrive. If we are online, we don't
// want to emit a false offline with the initial accumulator, so we wait a bit before
// emitting, and rely on `networksWithInternetConnectivity`.
//
// Also if our initial state was "online", but it just got turned off we might not see
// any updates for this network even though we already were registered for updated, and
// thus we can't drop initial value accumulator value.
fun ConnectivityManager.hasInternetConnectivity(
resolver: UnderlyingConnectivityStatusResolver
): Flow<Connectivity.Status> =
this.defaultRawNetworkStateFlow()
.debounce(CONNECTIVITY_DEBOUNCE)
.onStart {
// We should not use this as initial state in scan, because it may contain networks
// that won't be included in `networkEvents` updates.
emit(networksWithInternetConnectivity().also { Logger.d("Networks (Initial): $it") })
}
.map { it.isNotEmpty() }
.map { resolveConnectivityStatus(it, resolver) }
.distinctUntilChanged()

@Suppress("DEPRECATION")
fun ConnectivityManager.networksWithInternetConnectivity(): Set<Network> =
// Currently the use of `allNetworks` (which is deprecated in favor of listening to network
// events) is our only option because network events does not give us the initial state fast
// enough.
allNetworks
.filter {
val capabilities = getNetworkCapabilities(it) ?: return@filter false

capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
}
.toSet()
internal fun resolveConnectivityStatus(
currentRawNetworkState: RawNetworkState?,
resolver: UnderlyingConnectivityStatusResolver,
): Connectivity.Status =
if (currentRawNetworkState.isVpn()) {
// If the default network is a VPN we need to use a socket to check
// the underlying network
resolver.currentStatus()
} else {
// If the default network is not a VPN we can check the addresses
// directly
currentRawNetworkState.toConnectivityStatus()
}

private fun RawNetworkState?.toConnectivityStatus() =
Connectivity.Status(
ipv4 = this?.linkProperties?.linkAddresses?.any { it.address is Inet4Address } == true,
ipv6 = this?.linkProperties?.linkAddresses?.any { it.address is Inet6Address } == true,
)

private fun RawNetworkState?.isVpn(): Boolean =
this?.networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) == false
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package net.mullvad.talpid.util

import arrow.core.Either
import arrow.core.raise.result
import co.touchlab.kermit.Logger
import java.net.DatagramSocket
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.InetSocketAddress
import net.mullvad.talpid.model.Connectivity

/** This class is used to check the ip version of the underlying network when a VPN is active. */
class UnderlyingConnectivityStatusResolver(
private val protect: (socket: DatagramSocket) -> Boolean
) {
fun currentStatus(): Connectivity.Status =
Connectivity.Status(ipv4 = hasIPv4(), ipv6 = hasIPv6())

private fun hasIPv4(): Boolean =
hasIpVersion(Inet4Address.getByName(PUBLIC_IPV4_ADDRESS), protect)

private fun hasIPv6(): Boolean =
hasIpVersion(Inet6Address.getByName(PUBLIC_IPV6_ADDRESS), protect)

// Fake a connection to a public ip address using a UDP socket.
// We don't care about the result of the connection, only that it is possible to create.
// This is done this way since otherwise there is not way to check the availability of an ip
// version on the underlying network if the VPN is turned on.
// Since we are protecting the socket it will use the underlying network regardless
// if the VPN is turned on or not.
// If the ip version is not supported on the underlying network it will trigger a socket
// exception. Otherwise we assume it is available.
private fun hasIpVersion(
ip: InetAddress,
protect: (socket: DatagramSocket) -> Boolean,
): Boolean =
result {
// Open socket
val socket = openSocket().bind()

val protected = protect(socket)

// Protect so we can get underlying network
if (!protected) {
// We shouldn't be doing this if we don't have a VPN, then we should of checked
// the network directly.
Logger.w("Failed to protect socket")
}

// "Connect" to public ip to see IP version is available
val address = InetSocketAddress(ip, 1)
socket.connectSafe(address).bind()
}
.isSuccess

private fun openSocket(): Either<Throwable, DatagramSocket> =
Either.catch { DatagramSocket() }.onLeft { Logger.e("Could not open socket or bind port") }

private fun DatagramSocket.connectSafe(address: InetSocketAddress): Either<Throwable, Unit> =
Either.catch { connect(address.address, address.port) }
.onLeft { Logger.e("Socket could not be set up") }
.also { close() }

companion object {
private const val PUBLIC_IPV4_ADDRESS = "1.1.1.1"
private const val PUBLIC_IPV6_ADDRESS = "2606:4700:4700::1001"
}
}
2 changes: 2 additions & 0 deletions mullvad-jni/src/classes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,6 @@ pub const CLASSES: &[&str] = &[
"net/mullvad/talpid/ConnectivityListener",
"net/mullvad/talpid/TalpidVpnService",
"net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointOverride",
"net/mullvad/talpid/model/Connectivity$Status",
"net/mullvad/talpid/model/Connectivity$PresumeOnline",
];
Loading
Loading