Skip to content

Commit f60e547

Browse files
committed
Merge branch 'expose-ip-version-availability-to-the-daemon-droid-1700'
2 parents a6543aa + 43cbbb5 commit f60e547

File tree

10 files changed

+359
-230
lines changed

10 files changed

+359
-230
lines changed

android/CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ Line wrap the file at 100 chars. Th
3434
### Removed
3535
- Remove Google's resolvers from encrypted DNS proxy.
3636

37+
### Fixed
38+
- Will no longer try to connect over IPv6 if IPv6 is not available.
39+
3740

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

android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/talpid/util/ConnectivityManagerUtilKtTest.kt

+153-62
Large diffs are not rendered by default.

android/lib/talpid/src/main/kotlin/net/mullvad/talpid/ConnectivityListener.kt

+26-7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import android.net.LinkProperties
55
import java.net.InetAddress
66
import kotlin.collections.ArrayList
77
import kotlinx.coroutines.CoroutineScope
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.FlowPreview
810
import kotlinx.coroutines.channels.Channel
911
import kotlinx.coroutines.flow.MutableStateFlow
1012
import kotlinx.coroutines.flow.SharingStarted
@@ -15,13 +17,22 @@ import kotlinx.coroutines.flow.onEach
1517
import kotlinx.coroutines.flow.receiveAsFlow
1618
import kotlinx.coroutines.flow.stateIn
1719
import kotlinx.coroutines.launch
20+
import kotlinx.coroutines.plus
21+
import kotlinx.coroutines.runBlocking
22+
import net.mullvad.talpid.model.Connectivity
1823
import net.mullvad.talpid.model.NetworkState
1924
import net.mullvad.talpid.util.RawNetworkState
25+
import net.mullvad.talpid.util.UnderlyingConnectivityStatusResolver
26+
import net.mullvad.talpid.util.activeRawNetworkState
2027
import net.mullvad.talpid.util.defaultRawNetworkStateFlow
2128
import net.mullvad.talpid.util.hasInternetConnectivity
29+
import net.mullvad.talpid.util.resolveConnectivityStatus
2230

23-
class ConnectivityListener(private val connectivityManager: ConnectivityManager) {
24-
private lateinit var _isConnected: StateFlow<Boolean>
31+
class ConnectivityListener(
32+
private val connectivityManager: ConnectivityManager,
33+
private val resolver: UnderlyingConnectivityStatusResolver,
34+
) {
35+
private lateinit var _isConnected: StateFlow<Connectivity>
2536
// Used by JNI
2637
val isConnected
2738
get() = _isConnected.value
@@ -37,6 +48,7 @@ class ConnectivityListener(private val connectivityManager: ConnectivityManager)
3748
val currentDnsServers: ArrayList<InetAddress>
3849
get() = _mutableNetworkState.value?.dnsServers ?: ArrayList()
3950

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

5466
_isConnected =
5567
connectivityManager
56-
.hasInternetConnectivity()
57-
.onEach { notifyConnectivityChange(it) }
68+
.hasInternetConnectivity(resolver)
69+
.onEach { notifyConnectivityChange(it.ipv4, it.ipv6) }
5870
.stateIn(
59-
scope,
71+
scope + Dispatchers.IO,
6072
SharingStarted.Eagerly,
61-
true, // Assume we have internet until we know otherwise
73+
// Has to happen on IO to avoid NetworkOnMainThreadException, we actually don't
74+
// send any traffic just open a socket to detect the IP version.
75+
runBlocking(Dispatchers.IO) {
76+
resolveConnectivityStatus(
77+
connectivityManager.activeRawNetworkState(),
78+
resolver,
79+
)
80+
},
6281
)
6382
}
6483

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

83-
private external fun notifyConnectivityChange(isConnected: Boolean)
102+
private external fun notifyConnectivityChange(isIPv4: Boolean, isIPv6: Boolean)
84103

85104
private external fun notifyDefaultNetworkChange(networkState: NetworkState?)
86105
}

android/lib/talpid/src/main/kotlin/net/mullvad/talpid/TalpidVpnService.kt

+6-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import net.mullvad.talpid.model.CreateTunResult.OtherAlwaysOnApp
2626
import net.mullvad.talpid.model.CreateTunResult.OtherLegacyAlwaysOnVpn
2727
import net.mullvad.talpid.model.TunConfig
2828
import net.mullvad.talpid.util.TalpidSdkUtils.setMeteredIfSupported
29+
import net.mullvad.talpid.util.UnderlyingConnectivityStatusResolver
2930

3031
open class TalpidVpnService : LifecycleVpnService() {
3132
private var activeTunStatus by
@@ -48,7 +49,11 @@ open class TalpidVpnService : LifecycleVpnService() {
4849
@CallSuper
4950
override fun onCreate() {
5051
super.onCreate()
51-
connectivityListener = ConnectivityListener(getSystemService<ConnectivityManager>()!!)
52+
connectivityListener =
53+
ConnectivityListener(
54+
getSystemService<ConnectivityManager>()!!,
55+
UnderlyingConnectivityStatusResolver(::protect),
56+
)
5257
connectivityListener.register(lifecycleScope)
5358
}
5459

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package net.mullvad.talpid.model
2+
3+
sealed class Connectivity {
4+
data class Status(val ipv4: Boolean, val ipv6: Boolean) : Connectivity()
5+
6+
// Required by jni
7+
data object PresumeOnline : Connectivity()
8+
}

android/lib/talpid/src/main/kotlin/net/mullvad/talpid/util/ConnectivityManagerUtil.kt

+52-111
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import android.net.ConnectivityManager.NetworkCallback
55
import android.net.LinkProperties
66
import android.net.Network
77
import android.net.NetworkCapabilities
8-
import android.net.NetworkRequest
98
import co.touchlab.kermit.Logger
9+
import java.net.Inet4Address
10+
import java.net.Inet6Address
1011
import kotlin.time.Duration.Companion.milliseconds
1112
import kotlinx.coroutines.FlowPreview
1213
import kotlinx.coroutines.channels.awaitClose
@@ -16,13 +17,12 @@ import kotlinx.coroutines.flow.callbackFlow
1617
import kotlinx.coroutines.flow.debounce
1718
import kotlinx.coroutines.flow.distinctUntilChanged
1819
import kotlinx.coroutines.flow.map
19-
import kotlinx.coroutines.flow.mapNotNull
20-
import kotlinx.coroutines.flow.onStart
2120
import kotlinx.coroutines.flow.scan
21+
import net.mullvad.talpid.model.Connectivity
2222

2323
private val CONNECTIVITY_DEBOUNCE = 300.milliseconds
2424

25-
internal fun ConnectivityManager.defaultNetworkEvents(): Flow<NetworkEvent> = callbackFlow {
25+
fun ConnectivityManager.defaultNetworkEvents(): Flow<NetworkEvent> = callbackFlow {
2626
val callback =
2727
object : NetworkCallback() {
2828
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
@@ -68,56 +68,6 @@ internal fun ConnectivityManager.defaultNetworkEvents(): Flow<NetworkEvent> = ca
6868
awaitClose { unregisterNetworkCallback(callback) }
6969
}
7070

71-
fun ConnectivityManager.networkEvents(networkRequest: NetworkRequest): Flow<NetworkEvent> =
72-
callbackFlow {
73-
val callback =
74-
object : NetworkCallback() {
75-
override fun onLinkPropertiesChanged(
76-
network: Network,
77-
linkProperties: LinkProperties,
78-
) {
79-
super.onLinkPropertiesChanged(network, linkProperties)
80-
trySendBlocking(NetworkEvent.LinkPropertiesChanged(network, linkProperties))
81-
}
82-
83-
override fun onAvailable(network: Network) {
84-
super.onAvailable(network)
85-
trySendBlocking(NetworkEvent.Available(network))
86-
}
87-
88-
override fun onCapabilitiesChanged(
89-
network: Network,
90-
networkCapabilities: NetworkCapabilities,
91-
) {
92-
super.onCapabilitiesChanged(network, networkCapabilities)
93-
trySendBlocking(NetworkEvent.CapabilitiesChanged(network, networkCapabilities))
94-
}
95-
96-
override fun onBlockedStatusChanged(network: Network, blocked: Boolean) {
97-
super.onBlockedStatusChanged(network, blocked)
98-
trySendBlocking(NetworkEvent.BlockedStatusChanged(network, blocked))
99-
}
100-
101-
override fun onLosing(network: Network, maxMsToLive: Int) {
102-
super.onLosing(network, maxMsToLive)
103-
trySendBlocking(NetworkEvent.Losing(network, maxMsToLive))
104-
}
105-
106-
override fun onLost(network: Network) {
107-
super.onLost(network)
108-
trySendBlocking(NetworkEvent.Lost(network))
109-
}
110-
111-
override fun onUnavailable() {
112-
super.onUnavailable()
113-
trySendBlocking(NetworkEvent.Unavailable)
114-
}
115-
}
116-
registerNetworkCallback(networkRequest, callback)
117-
118-
awaitClose { unregisterNetworkCallback(callback) }
119-
}
120-
12171
internal fun ConnectivityManager.defaultRawNetworkStateFlow(): Flow<RawNetworkState?> =
12272
defaultNetworkEvents().scan(null as RawNetworkState?) { state, event -> state.reduce(event) }
12373

@@ -153,74 +103,65 @@ sealed interface NetworkEvent {
153103
data class Lost(val network: Network) : NetworkEvent
154104
}
155105

156-
internal data class RawNetworkState(
106+
data class RawNetworkState(
157107
val network: Network,
158108
val linkProperties: LinkProperties? = null,
159109
val networkCapabilities: NetworkCapabilities? = null,
160110
val blockedStatus: Boolean = false,
161111
val maxMsToLive: Int? = null,
162112
)
163113

164-
private val nonVPNInternetNetworksRequest =
165-
NetworkRequest.Builder()
166-
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
167-
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
168-
.build()
169-
170-
private sealed interface InternalConnectivityEvent {
171-
data class Available(val network: Network) : InternalConnectivityEvent
172-
173-
data class Lost(val network: Network) : InternalConnectivityEvent
174-
}
114+
internal fun ConnectivityManager.activeRawNetworkState(): RawNetworkState? =
115+
try {
116+
activeNetwork?.let { currentNetwork: Network ->
117+
RawNetworkState(
118+
network = currentNetwork,
119+
linkProperties = getLinkProperties(currentNetwork),
120+
networkCapabilities = getNetworkCapabilities(currentNetwork),
121+
)
122+
}
123+
} catch (_: RuntimeException) {
124+
Logger.e(
125+
"Unable to get active network or properties and capabilities of the active network"
126+
)
127+
null
128+
}
175129

176130
/**
177-
* Return a flow notifying us if we have internet connectivity. Initial state will be taken from
178-
* `allNetworks` and then updated when network events occur. Important to note that `allNetworks`
179-
* may return a network that we never get updates from if turned off at the moment of the initial
180-
* query.
131+
* Return a flow with the current internet connectivity status. The status is based on current
132+
* default network and depending on if it is a VPN. If it is not a VPN we check the network
133+
* properties directly and if it is a VPN we use a socket to check the underlying network. A
134+
* debounce is applied to avoid emitting too many events and to avoid setting the app in an offline
135+
* state when switching networks.
181136
*/
182137
@OptIn(FlowPreview::class)
183-
fun ConnectivityManager.hasInternetConnectivity(): Flow<Boolean> =
184-
networkEvents(nonVPNInternetNetworksRequest)
185-
.mapNotNull {
186-
when (it) {
187-
is NetworkEvent.Available -> InternalConnectivityEvent.Available(it.network)
188-
is NetworkEvent.Lost -> InternalConnectivityEvent.Lost(it.network)
189-
else -> null
190-
}
191-
}
192-
.scan(emptySet<Network>()) { networks, event ->
193-
when (event) {
194-
is InternalConnectivityEvent.Lost -> networks - event.network
195-
is InternalConnectivityEvent.Available -> networks + event.network
196-
}.also { Logger.d("Networks: $it") }
197-
}
198-
// NetworkEvents are slow, can several 100 millis to arrive. If we are online, we don't
199-
// want to emit a false offline with the initial accumulator, so we wait a bit before
200-
// emitting, and rely on `networksWithInternetConnectivity`.
201-
//
202-
// Also if our initial state was "online", but it just got turned off we might not see
203-
// any updates for this network even though we already were registered for updated, and
204-
// thus we can't drop initial value accumulator value.
138+
fun ConnectivityManager.hasInternetConnectivity(
139+
resolver: UnderlyingConnectivityStatusResolver
140+
): Flow<Connectivity.Status> =
141+
this.defaultRawNetworkStateFlow()
205142
.debounce(CONNECTIVITY_DEBOUNCE)
206-
.onStart {
207-
// We should not use this as initial state in scan, because it may contain networks
208-
// that won't be included in `networkEvents` updates.
209-
emit(networksWithInternetConnectivity().also { Logger.d("Networks (Initial): $it") })
210-
}
211-
.map { it.isNotEmpty() }
143+
.map { resolveConnectivityStatus(it, resolver) }
212144
.distinctUntilChanged()
213145

214-
@Suppress("DEPRECATION")
215-
fun ConnectivityManager.networksWithInternetConnectivity(): Set<Network> =
216-
// Currently the use of `allNetworks` (which is deprecated in favor of listening to network
217-
// events) is our only option because network events does not give us the initial state fast
218-
// enough.
219-
allNetworks
220-
.filter {
221-
val capabilities = getNetworkCapabilities(it) ?: return@filter false
222-
223-
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
224-
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
225-
}
226-
.toSet()
146+
internal fun resolveConnectivityStatus(
147+
currentRawNetworkState: RawNetworkState?,
148+
resolver: UnderlyingConnectivityStatusResolver,
149+
): Connectivity.Status =
150+
if (currentRawNetworkState.isVpn()) {
151+
// If the default network is a VPN we need to use a socket to check
152+
// the underlying network
153+
resolver.currentStatus()
154+
} else {
155+
// If the default network is not a VPN we can check the addresses
156+
// directly
157+
currentRawNetworkState.toConnectivityStatus()
158+
}
159+
160+
private fun RawNetworkState?.toConnectivityStatus() =
161+
Connectivity.Status(
162+
ipv4 = this?.linkProperties?.linkAddresses?.any { it.address is Inet4Address } == true,
163+
ipv6 = this?.linkProperties?.linkAddresses?.any { it.address is Inet6Address } == true,
164+
)
165+
166+
private fun RawNetworkState?.isVpn(): Boolean =
167+
this?.networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) == false
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package net.mullvad.talpid.util
2+
3+
import arrow.core.Either
4+
import arrow.core.raise.result
5+
import co.touchlab.kermit.Logger
6+
import java.net.DatagramSocket
7+
import java.net.Inet4Address
8+
import java.net.Inet6Address
9+
import java.net.InetAddress
10+
import java.net.InetSocketAddress
11+
import net.mullvad.talpid.model.Connectivity
12+
13+
/** This class is used to check the ip version of the underlying network when a VPN is active. */
14+
class UnderlyingConnectivityStatusResolver(
15+
private val protect: (socket: DatagramSocket) -> Boolean
16+
) {
17+
fun currentStatus(): Connectivity.Status =
18+
Connectivity.Status(ipv4 = hasIPv4(), ipv6 = hasIPv6())
19+
20+
private fun hasIPv4(): Boolean =
21+
hasIpVersion(Inet4Address.getByName(PUBLIC_IPV4_ADDRESS), protect)
22+
23+
private fun hasIPv6(): Boolean =
24+
hasIpVersion(Inet6Address.getByName(PUBLIC_IPV6_ADDRESS), protect)
25+
26+
// Fake a connection to a public ip address using a UDP socket.
27+
// We don't care about the result of the connection, only that it is possible to create.
28+
// This is done this way since otherwise there is not way to check the availability of an ip
29+
// version on the underlying network if the VPN is turned on.
30+
// Since we are protecting the socket it will use the underlying network regardless
31+
// if the VPN is turned on or not.
32+
// If the ip version is not supported on the underlying network it will trigger a socket
33+
// exception. Otherwise we assume it is available.
34+
private fun hasIpVersion(
35+
ip: InetAddress,
36+
protect: (socket: DatagramSocket) -> Boolean,
37+
): Boolean =
38+
result {
39+
// Open socket
40+
val socket = openSocket().bind()
41+
42+
val protected = protect(socket)
43+
44+
// Protect so we can get underlying network
45+
if (!protected) {
46+
// We shouldn't be doing this if we don't have a VPN, then we should of checked
47+
// the network directly.
48+
Logger.w("Failed to protect socket")
49+
}
50+
51+
// "Connect" to public ip to see IP version is available
52+
val address = InetSocketAddress(ip, 1)
53+
socket.connectSafe(address).bind()
54+
}
55+
.isSuccess
56+
57+
private fun openSocket(): Either<Throwable, DatagramSocket> =
58+
Either.catch { DatagramSocket() }.onLeft { Logger.e("Could not open socket or bind port") }
59+
60+
private fun DatagramSocket.connectSafe(address: InetSocketAddress): Either<Throwable, Unit> =
61+
Either.catch { connect(address.address, address.port) }
62+
.onLeft { Logger.e("Socket could not be set up") }
63+
.also { close() }
64+
65+
companion object {
66+
private const val PUBLIC_IPV4_ADDRESS = "1.1.1.1"
67+
private const val PUBLIC_IPV6_ADDRESS = "2606:4700:4700::1001"
68+
}
69+
}

mullvad-jni/src/classes.rs

+2
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@ pub const CLASSES: &[&str] = &[
1818
"net/mullvad/talpid/ConnectivityListener",
1919
"net/mullvad/talpid/TalpidVpnService",
2020
"net/mullvad/mullvadvpn/lib/endpoint/ApiEndpointOverride",
21+
"net/mullvad/talpid/model/Connectivity$Status",
22+
"net/mullvad/talpid/model/Connectivity$PresumeOnline",
2123
];

0 commit comments

Comments
 (0)