Skip to content

Commit 330b967

Browse files
dlonPururun
andcommitted
Track IPv6 connectivity on Android
Co-authored-by: Jonatan Rhoidn <jonatan.rhodin@mullvad.net>
1 parent 629780d commit 330b967

File tree

8 files changed

+169
-58
lines changed

8 files changed

+169
-58
lines changed

android/CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ Line wrap the file at 100 chars. Th
2929
### Removed
3030
- Remove Google's resolvers from encrypted DNS proxy.
3131

32+
### Fixed
33+
- Will no longer try to connect over IPv6 if IPv6 is not available.
34+
3235

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

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

+75-8
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,44 @@ package net.mullvad.talpid
22

33
import android.net.ConnectivityManager
44
import android.net.LinkProperties
5+
import android.net.Network
6+
import android.net.NetworkCapabilities
7+
import co.touchlab.kermit.Logger
8+
import java.net.DatagramSocket
9+
import java.net.Inet4Address
10+
import java.net.Inet6Address
511
import java.net.InetAddress
612
import kotlin.collections.ArrayList
13+
import kotlin.time.Duration.Companion.milliseconds
714
import kotlinx.coroutines.CoroutineScope
15+
import kotlinx.coroutines.Dispatchers
16+
import kotlinx.coroutines.FlowPreview
817
import kotlinx.coroutines.channels.Channel
918
import kotlinx.coroutines.flow.MutableStateFlow
1019
import kotlinx.coroutines.flow.SharingStarted
1120
import kotlinx.coroutines.flow.StateFlow
21+
import kotlinx.coroutines.flow.debounce
22+
import kotlinx.coroutines.flow.distinctUntilChanged
23+
import kotlinx.coroutines.flow.filter
1224
import kotlinx.coroutines.flow.map
1325
import kotlinx.coroutines.flow.merge
1426
import kotlinx.coroutines.flow.onEach
1527
import kotlinx.coroutines.flow.receiveAsFlow
1628
import kotlinx.coroutines.flow.stateIn
1729
import kotlinx.coroutines.launch
30+
import kotlinx.coroutines.plus
31+
import kotlinx.coroutines.runBlocking
32+
import net.mullvad.talpid.model.Connectivity
1833
import net.mullvad.talpid.model.NetworkState
34+
import net.mullvad.talpid.util.IpUtils
1935
import net.mullvad.talpid.util.RawNetworkState
2036
import net.mullvad.talpid.util.defaultRawNetworkStateFlow
21-
import net.mullvad.talpid.util.hasInternetConnectivity
2237

23-
class ConnectivityListener(private val connectivityManager: ConnectivityManager) {
24-
private lateinit var _isConnected: StateFlow<Boolean>
38+
class ConnectivityListener(
39+
private val connectivityManager: ConnectivityManager,
40+
val protect: (socket: DatagramSocket) -> Boolean,
41+
) {
42+
private lateinit var _isConnected: StateFlow<Connectivity>
2543
// Used by JNI
2644
val isConnected
2745
get() = _isConnected.value
@@ -37,6 +55,7 @@ class ConnectivityListener(private val connectivityManager: ConnectivityManager)
3755
val currentDnsServers: ArrayList<InetAddress>
3856
get() = _mutableNetworkState.value?.dnsServers ?: ArrayList()
3957

58+
@OptIn(FlowPreview::class)
4059
fun register(scope: CoroutineScope) {
4160
// Consider implementing retry logic for the flows below, because registering a listener on
4261
// the default network may fail if the network on Android 11
@@ -53,15 +72,59 @@ class ConnectivityListener(private val connectivityManager: ConnectivityManager)
5372

5473
_isConnected =
5574
connectivityManager
56-
.hasInternetConnectivity()
57-
.onEach { notifyConnectivityChange(it) }
75+
.defaultRawNetworkStateFlow()
76+
.debounce(300.milliseconds)
77+
.map { it.toConnectivity() }
78+
.distinctUntilChanged()
79+
.onEach { notifyConnectivityChange(it.ipv4, it.ipv6) }
5880
.stateIn(
59-
scope,
81+
scope + Dispatchers.IO,
6082
SharingStarted.Eagerly,
61-
true, // Assume we have internet until we know otherwise
83+
// Has to happen on IO to avoid NetworkOnMainThreadException, we actually don't
84+
// send any traffic just open a socket to detect the IP version.
85+
runBlocking(Dispatchers.IO) {
86+
connectivityManager.activeRawNetworkState().toConnectivity()
87+
},
6288
)
6389
}
6490

91+
private fun ConnectivityManager.activeRawNetworkState(): RawNetworkState? =
92+
try {
93+
activeNetwork?.let { initialNetwork: Network ->
94+
RawNetworkState(
95+
network = initialNetwork,
96+
linkProperties = getLinkProperties(initialNetwork),
97+
networkCapabilities = getNetworkCapabilities(initialNetwork),
98+
)
99+
}
100+
} catch (_: RuntimeException) {
101+
Logger.e(
102+
"Unable to get active network or properties and capabilities of the active network"
103+
)
104+
null
105+
}
106+
107+
private fun RawNetworkState?.toConnectivity(): Connectivity.Status =
108+
if (isVpn()) {
109+
// If the default network is a VPN we need to use a socket to check
110+
// the underlying network
111+
Connectivity.Status(
112+
IpUtils.hasIPv4(protect = { protect(it) }),
113+
IpUtils.hasIPv6(protect = { protect(it) }),
114+
)
115+
} else {
116+
// If the default network is not a VPN we can check the addresses
117+
// directly
118+
Connectivity.Status(
119+
ipv4 =
120+
this?.linkProperties?.routes?.any { it.destination.address is Inet4Address } ==
121+
true,
122+
ipv6 =
123+
this?.linkProperties?.routes?.any { it.destination.address is Inet6Address } ==
124+
true,
125+
)
126+
}
127+
65128
/**
66129
* Invalidates the network state cache. E.g when the VPN is connected or disconnected, and we
67130
* know the last known values not to be correct anymore.
@@ -73,14 +136,18 @@ class ConnectivityListener(private val connectivityManager: ConnectivityManager)
73136
private fun LinkProperties.dnsServersWithoutFallback(): List<InetAddress> =
74137
dnsServers.filter { it.hostAddress != TalpidVpnService.FALLBACK_DUMMY_DNS_SERVER }
75138

139+
private fun RawNetworkState?.isVpn(): Boolean =
140+
this?.networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN) ==
141+
false
142+
76143
private fun RawNetworkState.toNetworkState(): NetworkState =
77144
NetworkState(
78145
network.networkHandle,
79146
linkProperties?.routes,
80147
linkProperties?.dnsServersWithoutFallback(),
81148
)
82149

83-
private external fun notifyConnectivityChange(isConnected: Boolean)
150+
private external fun notifyConnectivityChange(isIPv4: Boolean, isIPv6: Boolean)
84151

85152
private external fun notifyDefaultNetworkChange(networkState: NetworkState?)
86153
}

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ open class TalpidVpnService : LifecycleVpnService() {
4848
@CallSuper
4949
override fun onCreate() {
5050
super.onCreate()
51-
connectivityListener = ConnectivityListener(getSystemService<ConnectivityManager>()!!)
51+
connectivityListener =
52+
ConnectivityListener(getSystemService<ConnectivityManager>()!!, ::protect)
5253
connectivityListener.register(lifecycleScope)
5354
}
5455

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package net.mullvad.talpid.model
2+
3+
sealed class Connectivity {
4+
data class Status(val ipv4: Boolean, val ipv6: Boolean) : Connectivity()
5+
6+
data object PresumeOnline : Connectivity()
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package net.mullvad.talpid.util
2+
3+
import co.touchlab.kermit.Logger
4+
import java.net.DatagramSocket
5+
import java.net.InetAddress
6+
import java.net.InetSocketAddress
7+
import java.net.SocketException
8+
9+
object IpUtils {
10+
fun hasIPv4(protect: (socket: DatagramSocket) -> Boolean): Boolean =
11+
hasIpVersion(InetAddress.getByName(PUBLIC_IPV4_ADDRESS), protect)
12+
13+
fun hasIPv6(protect: (socket: DatagramSocket) -> Boolean): Boolean =
14+
hasIpVersion(InetAddress.getByName(PUBLIC_IPV6_ADDRESS), protect)
15+
16+
// Fake a connection to a public ip address using a UDP socket.
17+
// We don't care about the result of the connection, only that it is possible to create.
18+
// This is done this way since otherwise there is not way to check the availability of an ip
19+
// version on the underlying network if the VPN is turned on.
20+
// Since we are protecting the socket it will use the underlying network regardless
21+
// if the VPN is turned on or not.
22+
// If the ip version is not supported on the underlying network it will trigger a socket
23+
// exception. Otherwise we assume it is available.
24+
private inline fun <reified T : InetAddress> hasIpVersion(
25+
ip: T,
26+
protect: (socket: DatagramSocket) -> Boolean,
27+
): Boolean {
28+
val socket = DatagramSocket()
29+
if (!protect(socket)) {
30+
Logger.e("Unable to protect the socket VPN is not set up correctly")
31+
return false
32+
}
33+
return try {
34+
socket.connect(InetSocketAddress(ip, 1))
35+
socket.localSocketAddress.also { Logger.d("Public Local address: $it") }
36+
true
37+
} catch (_: SocketException) {
38+
Logger.e("Socket could not be set up")
39+
false
40+
} finally {
41+
socket.close()
42+
}
43+
}
44+
45+
private const val PUBLIC_IPV4_ADDRESS = "1.1.1.1"
46+
private const val PUBLIC_IPV6_ADDRESS = "2606:4700:4700::1001"
47+
}

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
];

talpid-core/src/connectivity_listener.rs

+29-26
Original file line numberDiff line numberDiff line change
@@ -98,36 +98,34 @@ impl ConnectivityListener {
9898

9999
/// Return the current offline/connectivity state
100100
pub fn connectivity(&self) -> Connectivity {
101-
self.get_is_connected()
102-
.map(|connected| Connectivity::Status { connected })
103-
.unwrap_or_else(|error| {
104-
log::error!(
105-
"{}",
106-
error.display_chain_with_msg("Failed to check connectivity status")
107-
);
108-
Connectivity::PresumeOnline
109-
})
101+
self.get_is_connected().unwrap_or_else(|error| {
102+
log::error!(
103+
"{}",
104+
error.display_chain_with_msg("Failed to check connectivity status")
105+
);
106+
Connectivity::PresumeOnline
107+
})
110108
}
111109

112-
fn get_is_connected(&self) -> Result<bool, Error> {
110+
fn get_is_connected(&self) -> Result<Connectivity, Error> {
113111
let env = JnixEnv::from(
114112
self.jvm
115113
.attach_current_thread_as_daemon()
116114
.map_err(Error::AttachJvmToThread)?,
117115
);
118116

119-
let is_connected =
120-
env.call_method(self.android_listener.as_obj(), "isConnected", "()Z", &[]);
121-
122-
match is_connected {
123-
Ok(JValue::Bool(JNI_TRUE)) => Ok(true),
124-
Ok(JValue::Bool(_)) => Ok(false),
125-
value => Err(Error::InvalidMethodResult(
126-
"ConnectivityListener",
117+
let is_connected = env
118+
.call_method(
119+
self.android_listener.as_obj(),
127120
"isConnected",
128-
format!("{:?}", value),
129-
)),
130-
}
121+
"()Lnet/mullvad/talpid/model/Connectivity;",
122+
&[],
123+
)
124+
.expect("Missing isConnected")
125+
.l()
126+
.expect("isConnected is not an object");
127+
128+
Ok(Connectivity::from_java(&env, is_connected))
131129
}
132130

133131
/// Return the current DNS servers according to Android
@@ -160,20 +158,25 @@ impl ConnectivityListener {
160158
#[unsafe(no_mangle)]
161159
#[allow(non_snake_case)]
162160
pub extern "system" fn Java_net_mullvad_talpid_ConnectivityListener_notifyConnectivityChange(
163-
_: JNIEnv<'_>,
164-
_: JObject<'_>,
165-
connected: jboolean,
161+
_env: JNIEnv<'_>,
162+
_obj: JObject<'_>,
163+
is_ipv4: jboolean,
164+
is_ipv6: jboolean,
166165
) {
167166
let Some(tx) = &*CONNECTIVITY_TX.lock().unwrap() else {
168167
// No sender has been registered
169168
log::trace!("Received connectivity notification wíth no channel");
170169
return;
171170
};
172171

173-
let connected = JNI_TRUE == connected;
172+
let is_ipv4 = JNI_TRUE == is_ipv4;
173+
let is_ipv6 = JNI_TRUE == is_ipv6;
174174

175175
if tx
176-
.unbounded_send(Connectivity::Status { connected })
176+
.unbounded_send(Connectivity::Status {
177+
ipv4: is_ipv4,
178+
ipv6: is_ipv6,
179+
})
177180
.is_err()
178181
{
179182
log::warn!("Failed to send offline change event");

talpid-types/src/net/mod.rs

+4-23
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use ipnetwork::{IpNetwork, Ipv4Network, Ipv6Network};
2+
#[cfg(target_os = "android")]
3+
use jnix::FromJava;
24
use obfuscation::ObfuscatorConfig;
35
use serde::{Deserialize, Serialize};
46
#[cfg(windows)]
@@ -566,19 +568,15 @@ pub fn all_of_the_internet() -> Vec<ipnetwork::IpNetwork> {
566568
/// Information about the host's connectivity, such as the preesence of
567569
/// configured IPv4 and/or IPv6.
568570
#[derive(Debug, Clone, Copy, PartialEq)]
571+
#[cfg_attr(target_os = "android", derive(FromJava))]
572+
#[cfg_attr(target_os = "android", jnix(package = "net.mullvad.talpid.model"))]
569573
pub enum Connectivity {
570-
#[cfg(not(target_os = "android"))]
571574
Status {
572575
/// Whether IPv4 connectivity seems to be available on the host.
573576
ipv4: bool,
574577
/// Whether IPv6 connectivity seems to be available on the host.
575578
ipv6: bool,
576579
},
577-
#[cfg(target_os = "android")]
578-
Status {
579-
/// Whether _any_ connectivity seems to be available on the host.
580-
connected: bool,
581-
},
582580
/// On/offline status could not be verified, but we have no particular
583581
/// reason to believe that the host is offline.
584582
PresumeOnline,
@@ -592,7 +590,6 @@ impl Connectivity {
592590

593591
/// If no IP4 nor IPv6 routes exist, we have no way of reaching the internet
594592
/// so we consider ourselves offline.
595-
#[cfg(not(target_os = "android"))]
596593
pub fn is_offline(&self) -> bool {
597594
matches!(
598595
self,
@@ -606,23 +603,7 @@ impl Connectivity {
606603
/// Whether IPv6 connectivity seems to be available on the host.
607604
///
608605
/// If IPv6 status is unknown, `false` is returned.
609-
#[cfg(not(target_os = "android"))]
610606
pub fn has_ipv6(&self) -> bool {
611607
matches!(self, Connectivity::Status { ipv6: true, .. })
612608
}
613-
614-
/// Whether IPv6 connectivity seems to be available on the host.
615-
///
616-
/// If IPv6 status is unknown, `false` is returned.
617-
#[cfg(target_os = "android")]
618-
pub fn has_ipv6(&self) -> bool {
619-
self.is_online()
620-
}
621-
622-
/// If the host does not have configured IPv6 routes, we have no way of
623-
/// reaching the internet so we consider ourselves offline.
624-
#[cfg(target_os = "android")]
625-
pub fn is_offline(&self) -> bool {
626-
matches!(self, Connectivity::Status { connected: false })
627-
}
628609
}

0 commit comments

Comments
 (0)