Skip to content

Commit e6389f9

Browse files
committed
android: fix Auto-VPN network evaluation stability
Fix several issues with the Auto-VPN network watcher: - Add debouncing (2s) to prevent rapid-fire callback evaluation - Track lastAction to deduplicate redundant enable/disable calls - Filter out VPN network events in callbacks to prevent feedback loops where starting VPN triggers new network events - Use cm.allNetworks instead of cm.activeNetwork in evaluateCurrent() so the underlying Wi-Fi network is found even when VPN is active - Add WifiManager.scanResults as third SSID detection fallback for Android 12+ where transportInfo and connectionInfo return unknown - Add diagnostic logging for SSID detection method and trusted list matching - Re-evaluate network state when user adds/removes trusted networks or toggles the Auto-VPN feature - Expose networkWatcher from App for ViewModel access
1 parent 1e58fba commit e6389f9

3 files changed

Lines changed: 100 additions & 25 deletions

File tree

android/src/main/java/com/tailscale/ipn/App.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
8989

9090
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
9191
var healthNotifier: HealthNotifier? = null
92-
private lateinit var networkWatcher: NetworkWatcher
92+
lateinit var networkWatcher: NetworkWatcher
93+
private set
9394

9495
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
9596

android/src/main/java/com/tailscale/ipn/autoconnect/NetworkWatcher.kt

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import android.net.NetworkCapabilities
1010
import android.net.NetworkRequest
1111
import android.net.wifi.WifiInfo
1212
import android.net.wifi.WifiManager
13+
import android.os.Handler
14+
import android.os.Looper
1315
import com.tailscale.ipn.App
1416
import com.tailscale.ipn.ui.model.Ipn
1517
import com.tailscale.ipn.ui.notifier.Notifier
@@ -21,6 +23,9 @@ class NetworkWatcher(private val context: Context) {
2123
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
2224

2325
private var registered = false
26+
private val handler = Handler(Looper.getMainLooper())
27+
private var pendingEvaluation: Runnable? = null
28+
private var lastAction: String? = null
2429

2530
fun register() {
2631
if (registered) return
@@ -40,6 +45,13 @@ class NetworkWatcher(private val context: Context) {
4045
registered = false
4146
}
4247

48+
private fun scheduleEvaluation() {
49+
pendingEvaluation?.let { handler.removeCallbacks(it) }
50+
val runnable = Runnable { evaluateCurrent() }
51+
pendingEvaluation = runnable
52+
handler.postDelayed(runnable, DEBOUNCE_MS)
53+
}
54+
4355
private val callback =
4456
object : ConnectivityManager.NetworkCallback() {
4557

@@ -48,38 +60,63 @@ class NetworkWatcher(private val context: Context) {
4860
caps: NetworkCapabilities
4961
) {
5062
if (!TrustedNetworks.isEnabled(context)) return
51-
52-
val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
53-
val isCellular = caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
54-
55-
when {
56-
isWifi -> handleWifi(caps)
57-
isCellular -> enableVpn()
58-
}
63+
// Ignore VPN network changes — they are created by us
64+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return
65+
scheduleEvaluation()
5966
}
6067

6168
override fun onLost(network: Network) {
6269
if (!TrustedNetworks.isEnabled(context)) return
63-
enableVpn()
70+
scheduleEvaluation()
6471
}
6572
}
6673

67-
private fun handleWifi(caps: NetworkCapabilities) {
74+
private fun getSsid(caps: NetworkCapabilities): String? {
75+
// Method 1: transportInfo from NetworkCapabilities
6876
var ssid =
6977
(caps.transportInfo as? WifiInfo)?.ssid?.trim('"')
7078
?.takeIf { it.isNotBlank() && it != "<unknown ssid>" }
79+
if (ssid != null) {
80+
TSLog.d(TAG, "SSID from transportInfo: $ssid")
81+
return ssid
82+
}
7183

72-
// Fallback to WifiManager
73-
if (ssid == null) {
74-
val wm = context.getSystemService(Context.WIFI_SERVICE) as WifiManager
75-
@Suppress("DEPRECATION")
76-
ssid = wm.connectionInfo?.ssid?.trim('"')
77-
?.takeIf { it.isNotBlank() && it != "<unknown ssid>" }
84+
// Method 2: WifiManager.connectionInfo (deprecated but works on some Android 12+ devices)
85+
val wm = context.getSystemService(Context.WIFI_SERVICE) as WifiManager
86+
@Suppress("DEPRECATION")
87+
ssid = wm.connectionInfo?.ssid?.trim('"')
88+
?.takeIf { it.isNotBlank() && it != "<unknown ssid>" }
89+
if (ssid != null) {
90+
TSLog.d(TAG, "SSID from WifiManager: $ssid")
91+
return ssid
7892
}
7993

94+
// Method 3: WifiManager.scanResults — match by BSSID from connectionInfo
95+
@Suppress("DEPRECATION")
96+
val bssid = wm.connectionInfo?.bssid
97+
if (bssid != null) {
98+
ssid = wm.scanResults
99+
?.firstOrNull { it.BSSID == bssid }
100+
?.SSID
101+
?.trim('"')
102+
?.takeIf { it.isNotBlank() }
103+
if (ssid != null) {
104+
TSLog.d(TAG, "SSID from scanResults: $ssid")
105+
return ssid
106+
}
107+
}
108+
109+
TSLog.d(TAG, "Could not determine SSID")
110+
return null
111+
}
112+
113+
private fun handleWifi(caps: NetworkCapabilities) {
114+
val ssid = getSsid(caps)
115+
80116
if (ssid == null) return
81117

82118
val trusted = TrustedNetworks.load(context)
119+
TSLog.d(TAG, "SSID='$ssid' trusted_list=$trusted match=${ssid in trusted}")
83120
if (ssid in trusted) {
84121
disableVpn()
85122
} else {
@@ -88,35 +125,64 @@ class NetworkWatcher(private val context: Context) {
88125
}
89126

90127
private fun enableVpn() {
128+
if (lastAction == "enable") return
91129
val state = Notifier.state.value
92-
if (state == Ipn.State.Running) return
130+
if (state == Ipn.State.Running) {
131+
lastAction = "enable"
132+
return
133+
}
134+
lastAction = "enable"
93135
TSLog.d(TAG, "Enabling VPN (auto-vpn)")
94136
App.get().startVPN()
95137
}
96138

97139
private fun disableVpn() {
140+
if (lastAction == "disable") return
98141
val state = Notifier.state.value
99-
if (state != Ipn.State.Running) return
142+
if (state != Ipn.State.Running) {
143+
lastAction = "disable"
144+
return
145+
}
146+
lastAction = "disable"
100147
TSLog.d(TAG, "Disabling VPN (trusted network)")
101148
App.get().stopVPN()
102149
}
103150

151+
fun reevaluate() {
152+
lastAction = null
153+
evaluateCurrent()
154+
}
155+
104156
fun evaluateCurrent() {
105157
if (!TrustedNetworks.isEnabled(context)) return
106-
val network = cm.activeNetwork ?: run { enableVpn(); return }
107-
val caps = cm.getNetworkCapabilities(network) ?: run { enableVpn(); return }
108158

109-
val isWifi = caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
110-
val isCellular = caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)
159+
// Find the underlying Wi-Fi or cellular network, skipping VPN
160+
var wifiCaps: NetworkCapabilities? = null
161+
var hasCellular = false
162+
163+
for (network in cm.allNetworks) {
164+
val caps = cm.getNetworkCapabilities(network) ?: continue
165+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue
166+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
167+
wifiCaps = caps
168+
break // Wi-Fi takes priority
169+
}
170+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
171+
hasCellular = true
172+
}
173+
}
174+
175+
TSLog.d(TAG, "evaluateCurrent: wifi=${wifiCaps != null} cellular=$hasCellular")
111176

112177
when {
113-
isWifi -> handleWifi(caps)
114-
isCellular -> enableVpn()
178+
wifiCaps != null -> handleWifi(wifiCaps)
179+
hasCellular -> enableVpn()
115180
else -> enableVpn()
116181
}
117182
}
118183

119184
companion object {
120185
private const val TAG = "NetworkWatcher"
186+
private const val DEBOUNCE_MS = 2000L
121187
}
122188
}

android/src/main/java/com/tailscale/ipn/ui/viewModel/TrustedNetworksViewModel.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import android.net.NetworkCapabilities
1010
import android.net.wifi.WifiInfo
1111
import android.net.wifi.WifiManager
1212
import androidx.lifecycle.AndroidViewModel
13+
import com.tailscale.ipn.App
1314
import com.tailscale.ipn.autoconnect.TrustedNetworks
1415
import kotlinx.coroutines.flow.MutableStateFlow
1516
import kotlinx.coroutines.flow.StateFlow
@@ -32,19 +33,26 @@ class TrustedNetworksViewModel(application: Application) : AndroidViewModel(appl
3233
refreshCurrentSsid()
3334
}
3435

36+
private fun reevaluate() {
37+
App.get().networkWatcher.reevaluate()
38+
}
39+
3540
fun setEnabled(enabled: Boolean) {
3641
TrustedNetworks.setEnabled(ctx, enabled)
3742
_enabled.value = enabled
43+
reevaluate()
3844
}
3945

4046
fun addSsid(ssid: String) {
4147
TrustedNetworks.add(ctx, ssid)
4248
_trustedSsids.value = TrustedNetworks.load(ctx)
49+
reevaluate()
4350
}
4451

4552
fun removeSsid(ssid: String) {
4653
TrustedNetworks.remove(ctx, ssid)
4754
_trustedSsids.value = TrustedNetworks.load(ctx)
55+
reevaluate()
4856
}
4957

5058
fun refreshCurrentSsid() {

0 commit comments

Comments
 (0)