Skip to content

Commit c0325be

Browse files
gitzone83mhajduczenia
authored andcommitted
android: add Auto-VPN to toggle VPN based on Wi-Fi network trust
Add an Auto-VPN Manager that automatically enables/disables the Tailscale VPN tunnel based on the device's current network connection. When enabled, VPN is disabled on user-configured trusted Wi-Fi networks and enabled automatically on untrusted Wi-Fi, cellular data, or when no network is connected (fail-secure). New files: - autoconnect/TrustedNetworks.kt: SharedPreferences persistence for trusted SSID list and feature toggle - autoconnect/NetworkWatcher.kt: ConnectivityManager.NetworkCallback with debounced evaluation, VPN network filtering, action deduplication, SSID retry on null, and triple SSID detection fallback - ui/view/TrustedNetworksView.kt: Jetpack Compose settings screen with feature toggle, current network detection, manual SSID entry, trusted network list, and runtime location permission handling - ui/viewModel/TrustedNetworksViewModel.kt: ViewModel with re-evaluation on settings changes Modified files: - App.kt: Register NetworkWatcher in onCreate()/onTerminate() - MainActivity.kt: Add "trustedNetworks" route and SettingsNav callback - SettingsView.kt: Add Auto-VPN menu entry in settings - SettingsViewModel.kt: Extend SettingsNav with onNavigateToTrustedNetworks - Permissions.kt: Add ACCESS_FINE_LOCATION to permissions list - AndroidManifest.xml: Add ACCESS_FINE_LOCATION permission - strings.xml: Add auto_vpn, permission_location string resources Updates tailscale/tailscale#19408 Signed-off-by: Marek Hajduczenia <mxhajduczenia@gmail.com>
1 parent e7fca1a commit c0325be

11 files changed

Lines changed: 570 additions & 1 deletion

File tree

android/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
66
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
7+
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
78
<uses-permission android:name="android.permission.INTERNET" />
89
<uses-permission
910
android:name="android.permission.WRITE_EXTERNAL_STORAGE"

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModelStore
2525
import androidx.lifecycle.ViewModelStoreOwner
2626
import androidx.security.crypto.EncryptedSharedPreferences
2727
import androidx.security.crypto.MasterKey
28+
import com.tailscale.ipn.autoconnect.NetworkWatcher
2829
import com.tailscale.ipn.mdm.MDMSettings
2930
import com.tailscale.ipn.mdm.MDMSettingsChangedReceiver
3031
import com.tailscale.ipn.ui.localapi.Client
@@ -88,6 +89,8 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
8889

8990
private val appViewModelStore: ViewModelStore by lazy { ViewModelStore() }
9091
var healthNotifier: HealthNotifier? = null
92+
lateinit var networkWatcher: NetworkWatcher
93+
private set
9194

9295
override fun getPlatformDNSConfig(): String = dns.dnsConfigAsString
9396

@@ -128,10 +131,14 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
128131
getString(R.string.health_channel_name),
129132
getString(R.string.health_channel_description),
130133
NotificationManagerCompat.IMPORTANCE_HIGH)
134+
135+
networkWatcher = NetworkWatcher(this)
136+
networkWatcher.register()
131137
}
132138

133139
override fun onTerminate() {
134140
super.onTerminate()
141+
networkWatcher.unregister()
135142
Notifier.stop()
136143
notificationManager.cancelAll()
137144
applicationScope.cancel()

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import com.tailscale.ipn.ui.view.SubnetRoutingView
9292
import com.tailscale.ipn.ui.view.TaildropDirView
9393
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
9494
import com.tailscale.ipn.ui.view.TailnetLockSetupView
95+
import com.tailscale.ipn.ui.view.TrustedNetworksView
9596
import com.tailscale.ipn.ui.view.UserSwitcherNav
9697
import com.tailscale.ipn.ui.view.UserSwitcherView
9798
import com.tailscale.ipn.ui.viewModel.AppViewModel
@@ -310,6 +311,9 @@ class MainActivity : ComponentActivity() {
310311
onNavigateToManagedBy = { navController.navigate("managedBy") },
311312
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
312313
onNavigateToPermissions = { navController.navigate("permissions") },
314+
onNavigateToTrustedNetworks = {
315+
navController.navigate("trustedNetworks")
316+
},
313317
onBackToSettings = backTo("settings"),
314318
onNavigateBackHome = backTo("main"))
315319
val exitNodePickerNav =
@@ -375,6 +379,9 @@ class MainActivity : ComponentActivity() {
375379
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
376380
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
377381
composable("subnetRouting") { SubnetRoutingView(backTo("settings")) }
382+
composable("trustedNetworks") {
383+
TrustedNetworksView(onNavigateBack = backTo("settings"))
384+
}
378385
composable("about") { AboutView(backTo("settings")) }
379386
composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) }
380387
composable("managedBy") { ManagedByView(backTo("settings")) }
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.autoconnect
5+
6+
import android.content.Context
7+
import android.net.ConnectivityManager
8+
import android.net.Network
9+
import android.net.NetworkCapabilities
10+
import android.net.NetworkRequest
11+
import android.net.wifi.WifiInfo
12+
import android.net.wifi.WifiManager
13+
import android.os.Handler
14+
import android.os.Looper
15+
import com.tailscale.ipn.App
16+
import com.tailscale.ipn.ui.model.Ipn
17+
import com.tailscale.ipn.ui.notifier.Notifier
18+
import com.tailscale.ipn.util.TSLog
19+
20+
class NetworkWatcher(private val context: Context) {
21+
22+
private val cm =
23+
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
24+
25+
private var registered = false
26+
private val handler = Handler(Looper.getMainLooper())
27+
private var pendingEvaluation: Runnable? = null
28+
private var lastAction: String? = null
29+
private var ssidRetryCount = 0
30+
31+
fun register() {
32+
if (registered) return
33+
val request =
34+
NetworkRequest.Builder()
35+
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
36+
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
37+
.build()
38+
cm.registerNetworkCallback(request, callback)
39+
registered = true
40+
evaluateCurrent()
41+
}
42+
43+
fun unregister() {
44+
if (!registered) return
45+
cm.unregisterNetworkCallback(callback)
46+
registered = false
47+
}
48+
49+
private fun scheduleEvaluation() {
50+
pendingEvaluation?.let { handler.removeCallbacks(it) }
51+
val runnable = Runnable { evaluateCurrent() }
52+
pendingEvaluation = runnable
53+
handler.postDelayed(runnable, DEBOUNCE_MS)
54+
}
55+
56+
private val callback =
57+
object : ConnectivityManager.NetworkCallback() {
58+
59+
override fun onCapabilitiesChanged(
60+
network: Network,
61+
caps: NetworkCapabilities
62+
) {
63+
if (!TrustedNetworks.isEnabled(context)) return
64+
// Ignore VPN network changes — they are created by us
65+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return
66+
scheduleEvaluation()
67+
}
68+
69+
override fun onLost(network: Network) {
70+
if (!TrustedNetworks.isEnabled(context)) return
71+
scheduleEvaluation()
72+
}
73+
}
74+
75+
private fun getSsid(caps: NetworkCapabilities): String? {
76+
// Method 1: transportInfo from NetworkCapabilities
77+
var ssid =
78+
(caps.transportInfo as? WifiInfo)?.ssid?.trim('"')
79+
?.takeIf { it.isNotBlank() && it != "<unknown ssid>" }
80+
if (ssid != null) {
81+
TSLog.d(TAG, "SSID from transportInfo: $ssid")
82+
return ssid
83+
}
84+
85+
// Method 2: WifiManager.connectionInfo (deprecated but works on some Android 12+ devices)
86+
val wm = context.getSystemService(Context.WIFI_SERVICE) as WifiManager
87+
@Suppress("DEPRECATION")
88+
ssid = wm.connectionInfo?.ssid?.trim('"')
89+
?.takeIf { it.isNotBlank() && it != "<unknown ssid>" }
90+
if (ssid != null) {
91+
TSLog.d(TAG, "SSID from WifiManager: $ssid")
92+
return ssid
93+
}
94+
95+
// Method 3: WifiManager.scanResults — match by BSSID from connectionInfo
96+
@Suppress("DEPRECATION")
97+
val bssid = wm.connectionInfo?.bssid
98+
if (bssid != null) {
99+
ssid = wm.scanResults
100+
?.firstOrNull { it.BSSID == bssid }
101+
?.SSID
102+
?.trim('"')
103+
?.takeIf { it.isNotBlank() }
104+
if (ssid != null) {
105+
TSLog.d(TAG, "SSID from scanResults: $ssid")
106+
return ssid
107+
}
108+
}
109+
110+
TSLog.d(TAG, "Could not determine SSID")
111+
return null
112+
}
113+
114+
private fun handleWifi(caps: NetworkCapabilities) {
115+
val ssid = getSsid(caps)
116+
117+
if (ssid == null) {
118+
// SSID may not be available yet after Wi-Fi association — retry a few times
119+
if (ssidRetryCount < MAX_SSID_RETRIES) {
120+
ssidRetryCount++
121+
TSLog.d(TAG, "SSID null on Wi-Fi, scheduling retry $ssidRetryCount/$MAX_SSID_RETRIES")
122+
handler.postDelayed({ evaluateCurrent() }, SSID_RETRY_DELAY_MS)
123+
}
124+
return
125+
}
126+
127+
ssidRetryCount = 0
128+
val trusted = TrustedNetworks.load(context)
129+
TSLog.d(TAG, "SSID='$ssid' trusted_list=$trusted match=${ssid in trusted}")
130+
if (ssid in trusted) {
131+
disableVpn()
132+
} else {
133+
enableVpn()
134+
}
135+
}
136+
137+
private fun enableVpn() {
138+
if (lastAction == "enable") return
139+
val state = Notifier.state.value
140+
if (state == Ipn.State.Running) {
141+
lastAction = "enable"
142+
return
143+
}
144+
lastAction = "enable"
145+
TSLog.d(TAG, "Enabling VPN (auto-vpn)")
146+
App.get().startVPN()
147+
}
148+
149+
private fun disableVpn() {
150+
if (lastAction == "disable") return
151+
val state = Notifier.state.value
152+
if (state != Ipn.State.Running) {
153+
lastAction = "disable"
154+
return
155+
}
156+
lastAction = "disable"
157+
TSLog.d(TAG, "Disabling VPN (trusted network)")
158+
App.get().stopVPN()
159+
}
160+
161+
fun reevaluate() {
162+
lastAction = null
163+
ssidRetryCount = 0
164+
evaluateCurrent()
165+
}
166+
167+
fun evaluateCurrent() {
168+
if (!TrustedNetworks.isEnabled(context)) return
169+
170+
// Find the underlying Wi-Fi or cellular network, skipping VPN
171+
var wifiCaps: NetworkCapabilities? = null
172+
var hasCellular = false
173+
174+
for (network in cm.allNetworks) {
175+
val caps = cm.getNetworkCapabilities(network) ?: continue
176+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue
177+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
178+
wifiCaps = caps
179+
break // Wi-Fi takes priority
180+
}
181+
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
182+
hasCellular = true
183+
}
184+
}
185+
186+
TSLog.d(TAG, "evaluateCurrent: wifi=${wifiCaps != null} cellular=$hasCellular")
187+
188+
when {
189+
wifiCaps != null -> handleWifi(wifiCaps)
190+
hasCellular -> enableVpn()
191+
else -> enableVpn()
192+
}
193+
}
194+
195+
companion object {
196+
private const val TAG = "NetworkWatcher"
197+
private const val DEBOUNCE_MS = 2000L
198+
private const val SSID_RETRY_DELAY_MS = 3000L
199+
private const val MAX_SSID_RETRIES = 5
200+
}
201+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.autoconnect
5+
6+
import android.content.Context
7+
8+
object TrustedNetworks {
9+
private const val PREFS_NAME = "tailscale_auto"
10+
private const val KEY_SSIDS = "trusted_ssids"
11+
private const val KEY_ENABLED = "auto_vpn_enabled"
12+
13+
fun isEnabled(ctx: Context): Boolean =
14+
ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
15+
.getBoolean(KEY_ENABLED, false)
16+
17+
fun setEnabled(ctx: Context, enabled: Boolean) =
18+
ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
19+
.edit()
20+
.putBoolean(KEY_ENABLED, enabled)
21+
.apply()
22+
23+
fun load(ctx: Context): Set<String> =
24+
ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
25+
.getStringSet(KEY_SSIDS, emptySet()) ?: emptySet()
26+
27+
fun save(ctx: Context, ssids: Set<String>) =
28+
ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
29+
.edit()
30+
.putStringSet(KEY_SSIDS, ssids)
31+
.apply()
32+
33+
fun add(ctx: Context, ssid: String) = save(ctx, load(ctx) + ssid)
34+
35+
fun remove(ctx: Context, ssid: String) = save(ctx, load(ctx) - ssid)
36+
}

android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ object Permissions {
7979
R.string.permission_post_notifications,
8080
R.string.permission_post_notifications_needed))
8181
}
82+
result.add(
83+
Permission(
84+
Manifest.permission.ACCESS_FINE_LOCATION,
85+
R.string.permission_location,
86+
R.string.permission_location_needed))
8287
result
8388
}
8489
}

android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ fun SettingsView(
111111
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
112112
}
113113

114+
Lists.ItemDivider()
115+
Setting.Text(
116+
R.string.auto_vpn,
117+
subtitle = "Auto-toggle VPN based on network",
118+
onClick = settingsNav.onNavigateToTrustedNetworks)
119+
114120
managedByOrganization.value?.let {
115121
Lists.ItemDivider()
116122
Setting.Text(
@@ -219,5 +225,5 @@ fun SettingsPreview() {
219225
vm.tailNetLockEnabled.set(true)
220226
vm.isAdmin.set(true)
221227
vm.managedByOrganization.set("Tails and Scales Inc.")
222-
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
228+
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
223229
}

0 commit comments

Comments
 (0)