diff options
| author | David Lönnhager <david.l@mullvad.net> | 2025-01-29 15:11:39 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-03-07 11:36:59 +0100 |
| commit | 43cbbb50f9bab8dece273e764d56a2c467032ac0 (patch) | |
| tree | f927960e89b6ac44a82f1739de8ffcb925fd0c83 /android/lib/talpid/src | |
| parent | a6543aa58a0fefaa52a78e87d5ddbd3112c7e0c1 (diff) | |
| download | mullvadvpn-43cbbb50f9bab8dece273e764d56a2c467032ac0.tar.xz mullvadvpn-43cbbb50f9bab8dece273e764d56a2c467032ac0.zip | |
Track IPv6 connectivity on Android
Co-authored-by: Jonatan Rhoidn <jonatan.rhodin@mullvad.net>
Co-authored-by: David Göransson <david.goransson@mullvad.net>
Diffstat (limited to 'android/lib/talpid/src')
5 files changed, 160 insertions, 118 deletions
diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt index c918d762ff..ede883a837 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt @@ -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 @@ -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 @@ -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 @@ -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, + ) + }, ) } @@ -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?) } diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt index a227c9a770..e353b8cc55 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt @@ -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 @@ -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) } diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt new file mode 100644 index 0000000000..b87eaaacc8 --- /dev/null +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/model/Connectivity.kt @@ -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() +} diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt index 89ddf425f5..7a0208eaa1 100644 --- a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt @@ -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 @@ -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) { @@ -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) } @@ -153,7 +103,7 @@ 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, @@ -161,66 +111,57 @@ internal data class RawNetworkState( 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 +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() + } - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) - } - .toSet() +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 diff --git a/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/UnderlyingConnectivityStatusResolver.kt b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/UnderlyingConnectivityStatusResolver.kt new file mode 100644 index 0000000000..620288fb72 --- /dev/null +++ b/android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/UnderlyingConnectivityStatusResolver.kt @@ -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" + } +} |
