@@ -10,6 +10,8 @@ import android.net.NetworkCapabilities
1010import android.net.NetworkRequest
1111import android.net.wifi.WifiInfo
1212import android.net.wifi.WifiManager
13+ import android.os.Handler
14+ import android.os.Looper
1315import com.tailscale.ipn.App
1416import com.tailscale.ipn.ui.model.Ipn
1517import 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}
0 commit comments