Skip to content

Commit 0fb3fc5

Browse files
achanuclaude
andcommitted
android: add Wi-Fi auto-connect feature
Implements automatic VPN control based on Wi-Fi network: - Whitelist (safe networks): VPN stops when joining, restarts on leave if "auto-start on unknown networks" is enabled - Blacklist (unsafe networks): VPN starts when joining, stops on leave - Unknown networks toggle: VPN starts on any unlisted network, stops on leave User overrides are respected per-session: - Manual VPN start on a whitelisted network suppresses auto-stop - Manual VPN stop on a blacklist/unknown network suppresses auto-restart until the next reconnection to that network SSID detection uses NetworkCapabilities.getTransportInfo() (API 31+) with a WifiManager.connectionInfo fallback. Requires ACCESS_FINE_LOCATION for SSID access on Android 13+. Settings UI accessible from Settings → Wi-Fi auto-connect. Each list supports manual SSID entry with a "use current network" shortcut when location permission is granted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d8f28f4 commit 0fb3fc5

10 files changed

Lines changed: 501 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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
171171
healthNotifier = HealthNotifier(Notifier.health, Notifier.state, applicationScope)
172172
connectivityManager = this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
173173
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
174+
NetworkChangeCallback.monitorWifiAutoConnect(connectivityManager, this)
174175
initViewModels()
175176
applicationScope.launch {
176177
val restrictionsManager =
@@ -521,6 +522,9 @@ open class UninitializedApp : Application() {
521522
private const val ALLOW_SELECTED_APPS_KEY = "allowSelectedApps"
522523

523524
private const val IS_CLIENT_LOGGING_ENABLED_KEY = "isClientLoggingEnabled"
525+
private const val WIFI_WHITELIST_SSIDS_KEY = "wifiAutoConnectSsids"
526+
private const val WIFI_BLACKLIST_SSIDS_KEY = "wifiBlacklistSsids"
527+
private const val WIFI_AUTO_CONNECT_DEFAULT_ON_KEY = "wifiAutoConnectDefaultOn"
524528
// File for shared preferences that are not encrypted.
525529
private const val UNENCRYPTED_PREFERENCES = "unencrypted"
526530
private lateinit var appInstance: UninitializedApp
@@ -752,6 +756,32 @@ open class UninitializedApp : Application() {
752756
return appViewModel
753757
}
754758

759+
// Whitelist = safe networks where VPN is stopped automatically.
760+
fun getWhitelistSsids(): Set<String> {
761+
return getUnencryptedPrefs().getStringSet(WIFI_WHITELIST_SSIDS_KEY, emptySet()) ?: emptySet()
762+
}
763+
764+
fun setWhitelistSsids(ssids: Set<String>) {
765+
getUnencryptedPrefs().edit().putStringSet(WIFI_WHITELIST_SSIDS_KEY, ssids).apply()
766+
}
767+
768+
// Blacklist = unsafe networks where VPN is always started automatically.
769+
fun getBlacklistSsids(): Set<String> {
770+
return getUnencryptedPrefs().getStringSet(WIFI_BLACKLIST_SSIDS_KEY, emptySet()) ?: emptySet()
771+
}
772+
773+
fun setBlacklistSsids(ssids: Set<String>) {
774+
getUnencryptedPrefs().edit().putStringSet(WIFI_BLACKLIST_SSIDS_KEY, ssids).apply()
775+
}
776+
777+
fun getWifiAutoConnectDefaultOn(): Boolean {
778+
return getUnencryptedPrefs().getBoolean(WIFI_AUTO_CONNECT_DEFAULT_ON_KEY, false)
779+
}
780+
781+
fun setWifiAutoConnectDefaultOn(enabled: Boolean) {
782+
getUnencryptedPrefs().edit().putBoolean(WIFI_AUTO_CONNECT_DEFAULT_ON_KEY, enabled).apply()
783+
}
784+
755785
val builtInDisallowedPackageNames: List<String> =
756786
listOf(
757787
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import com.tailscale.ipn.ui.view.RunExitNodeView
8888
import com.tailscale.ipn.ui.view.SearchView
8989
import com.tailscale.ipn.ui.view.SettingsView
9090
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
91+
import com.tailscale.ipn.ui.view.WifiAutoConnectView
9192
import com.tailscale.ipn.ui.view.SubnetRoutingView
9293
import com.tailscale.ipn.ui.view.TaildropDirView
9394
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
@@ -310,6 +311,9 @@ class MainActivity : ComponentActivity() {
310311
onNavigateToManagedBy = { navController.navigate("managedBy") },
311312
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
312313
onNavigateToPermissions = { navController.navigate("permissions") },
314+
onNavigateToWifiAutoConnect = {
315+
navController.navigate("wifiAutoConnect")
316+
},
313317
onBackToSettings = backTo("settings"),
314318
onNavigateBackHome = backTo("main"))
315319
val exitNodePickerNav =
@@ -373,6 +377,7 @@ class MainActivity : ComponentActivity() {
373377
composable("bugReport") { BugReportView(backTo("settings")) }
374378
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
375379
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
380+
composable("wifiAutoConnect") { WifiAutoConnectView(backTo("settings")) }
376381
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
377382
composable("subnetRouting") { SubnetRoutingView(backTo("settings")) }
378383
composable("about") { AboutView(backTo("settings")) }

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

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22
// SPDX-License-Identifier: BSD-3-Clause
33
package com.tailscale.ipn
44

5+
import android.content.Context
56
import android.net.ConnectivityManager
67
import android.net.LinkProperties
78
import android.net.Network
89
import android.net.NetworkCapabilities
910
import android.net.NetworkRequest
11+
import android.net.wifi.WifiInfo
12+
import android.net.wifi.WifiManager
13+
import android.os.Build
1014
import android.util.Log
15+
import androidx.work.ExistingWorkPolicy
16+
import androidx.work.OneTimeWorkRequest
17+
import androidx.work.WorkManager
1118
import com.tailscale.ipn.util.TSLog
1219
import java.util.concurrent.locks.ReentrantLock
1320
import kotlin.concurrent.withLock
@@ -37,6 +44,22 @@ object NetworkChangeCallback {
3744
var cachedDefaultInterfaceName: String? = null
3845
private set
3946

47+
// Networks where we auto-started VPN (blacklist or defaultOn). Tracked to auto-stop on leave.
48+
private val startedForNetworks = mutableMapOf<Network, String>()
49+
50+
// Networks where we auto-stopped VPN (whitelist). Tracked to restart VPN on leave if defaultOn.
51+
private val stoppedForNetworks = mutableMapOf<Network, String>()
52+
53+
// User manually stopped VPN on a blacklist/defaultOn network → suppress re-auto-start.
54+
private val userStoppedOnNetworks = mutableSetOf<Network>()
55+
56+
// User manually started VPN on a whitelist network → suppress auto-stop.
57+
private val userStartedOnWhitelist = mutableSetOf<Network>()
58+
59+
// Stored for checkExistingNetworks().
60+
private var wifiAutoConnectivity: ConnectivityManager? = null
61+
private var wifiAutoConnectApp: UninitializedApp? = null
62+
4063
// monitorDnsChanges sets up a network callback to monitor changes to the
4164
// system's network state and update the DNS configuration when interfaces
4265
// become available or properties of those interfaces change.
@@ -208,4 +231,146 @@ object NetworkChangeCallback {
208231
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
209232
}
210233
}
234+
235+
// userStoppedVpn: user manually stopped VPN → suppress re-auto-start on current networks.
236+
fun userStoppedVpn() {
237+
lock.withLock { userStoppedOnNetworks.addAll(startedForNetworks.keys) }
238+
}
239+
240+
// userStartedVpn: user manually started VPN → suppress auto-stop on whitelist networks.
241+
fun userStartedVpn() {
242+
lock.withLock { userStartedOnWhitelist.addAll(stoppedForNetworks.keys) }
243+
}
244+
245+
// monitorWifiAutoConnect registers a callback to auto-start the VPN on trusted SSIDs and
246+
// auto-stop it when leaving those networks.
247+
fun monitorWifiAutoConnect(connectivityManager: ConnectivityManager, app: UninitializedApp) {
248+
wifiAutoConnectivity = connectivityManager
249+
wifiAutoConnectApp = app
250+
val request =
251+
NetworkRequest.Builder()
252+
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
253+
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
254+
.build()
255+
256+
connectivityManager.registerNetworkCallback(
257+
request,
258+
object : ConnectivityManager.NetworkCallback() {
259+
override fun onCapabilitiesChanged(
260+
network: Network,
261+
capabilities: NetworkCapabilities
262+
) {
263+
super.onCapabilitiesChanged(network, capabilities)
264+
if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return
265+
val ssid = getSsid(capabilities, app) ?: return
266+
lock.withLock {
267+
val whitelist = app.getWhitelistSsids()
268+
val blacklist = app.getBlacklistSsids()
269+
val defaultOn = app.getWifiAutoConnectDefaultOn()
270+
val isWhitelisted = whitelist.contains(ssid)
271+
val isBlacklisted = blacklist.contains(ssid)
272+
TSLog.d(TAG, "wifi: ssid=$ssid whitelist=$whitelist blacklist=$blacklist defaultOn=$defaultOn")
273+
when {
274+
isWhitelisted -> {
275+
if (network in userStartedOnWhitelist) return@withLock
276+
if (network in stoppedForNetworks) return@withLock
277+
TSLog.d(TAG, "wifi: whitelist → stopping VPN")
278+
stoppedForNetworks[network] = ssid
279+
app.stopVPN()
280+
}
281+
isBlacklisted || defaultOn -> {
282+
if (network in userStoppedOnNetworks) return@withLock
283+
if (network in startedForNetworks) return@withLock
284+
if (!app.isAbleToStartVPN()) return@withLock
285+
TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → starting VPN")
286+
startedForNetworks[network] = ssid
287+
val req = OneTimeWorkRequest.Builder(StartVPNWorker::class.java).build()
288+
WorkManager.getInstance(app).enqueueUniqueWork(
289+
"wifi_auto_connect", ExistingWorkPolicy.KEEP, req)
290+
}
291+
else -> TSLog.d(TAG, "wifi: unknown network, defaultOn=false → no action")
292+
}
293+
}
294+
}
295+
296+
override fun onLost(network: Network) {
297+
super.onLost(network)
298+
lock.withLock {
299+
val startedSsid = startedForNetworks.remove(network)
300+
val stoppedSsid = stoppedForNetworks.remove(network)
301+
userStoppedOnNetworks.remove(network)
302+
userStartedOnWhitelist.remove(network)
303+
if (startedSsid != null) {
304+
TSLog.d(TAG, "wifi: left blacklist/defaultOn network $startedSsid → stopping VPN")
305+
app.stopVPN()
306+
}
307+
if (stoppedSsid != null && app.getWifiAutoConnectDefaultOn()) {
308+
TSLog.d(TAG, "wifi: left whitelist network $stoppedSsid, defaultOn=true → starting VPN")
309+
val req = OneTimeWorkRequest.Builder(StartVPNWorker::class.java).build()
310+
WorkManager.getInstance(app).enqueueUniqueWork(
311+
"wifi_auto_connect", ExistingWorkPolicy.KEEP, req)
312+
}
313+
}
314+
}
315+
})
316+
}
317+
318+
// checkExistingNetworks re-evaluates all active WiFi networks against current lists.
319+
// Call when whitelist, blacklist, or defaultOn setting changes.
320+
fun checkExistingNetworks() {
321+
wifiAutoConnectivity ?: return
322+
val app = wifiAutoConnectApp ?: return
323+
lock.withLock {
324+
val whitelist = app.getWhitelistSsids()
325+
val blacklist = app.getBlacklistSsids()
326+
val defaultOn = app.getWifiAutoConnectDefaultOn()
327+
for ((network, info) in activeNetworks) {
328+
if (!info.caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue
329+
val ssid = getSsidFromCaps(info.caps, app) ?: continue
330+
val isWhitelisted = whitelist.contains(ssid)
331+
val isBlacklisted = blacklist.contains(ssid)
332+
when {
333+
isWhitelisted -> {
334+
if (network in userStartedOnWhitelist) continue
335+
if (network in stoppedForNetworks) continue
336+
stoppedForNetworks[network] = ssid
337+
app.stopVPN()
338+
}
339+
isBlacklisted || defaultOn -> {
340+
if (network in userStoppedOnNetworks) continue
341+
if (network in startedForNetworks) continue
342+
if (!app.isAbleToStartVPN()) continue
343+
startedForNetworks[network] = ssid
344+
val req = OneTimeWorkRequest.Builder(StartVPNWorker::class.java).build()
345+
WorkManager.getInstance(app).enqueueUniqueWork(
346+
"wifi_auto_connect", ExistingWorkPolicy.KEEP, req)
347+
}
348+
}
349+
}
350+
}
351+
}
352+
353+
private fun getSsid(capabilities: NetworkCapabilities, app: UninitializedApp): String? =
354+
getSsidFromCaps(capabilities, app)
355+
356+
private fun getSsidFromCaps(capabilities: NetworkCapabilities, app: UninitializedApp): String? {
357+
// Try transportInfo first (preferred on API 29+, no location perm needed on API 31+).
358+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
359+
val fromTransport =
360+
(capabilities.transportInfo as? WifiInfo)
361+
?.ssid
362+
?.removePrefix("\"")
363+
?.removeSuffix("\"")
364+
?.takeIf { it.isNotEmpty() && it != "<unknown ssid>" }
365+
if (fromTransport != null) return fromTransport
366+
}
367+
// Fallback: WifiManager (works if ACCESS_FINE_LOCATION or NEARBY_WIFI_DEVICES is granted).
368+
@Suppress("DEPRECATION")
369+
return (app.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
370+
?.connectionInfo
371+
?.ssid
372+
?.removePrefix("\"")
373+
?.removeSuffix("\"")
374+
?.takeIf { it.isNotEmpty() && it != "<unknown ssid>" }
375+
}
211376
}

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
@@ -99,6 +99,12 @@ fun SettingsView(
9999
subtitle = stringResource(R.string.filter_apps_allowed_to_access_tailscale),
100100
onClick = settingsNav.onNavigateToSplitTunneling)
101101

102+
Lists.ItemDivider()
103+
Setting.Text(
104+
R.string.wifi_auto_connect,
105+
subtitle = stringResource(R.string.wifi_auto_connect_subtitle),
106+
onClick = settingsNav.onNavigateToWifiAutoConnect)
107+
102108
if (showTailnetLock.value == ShowHide.Show) {
103109
Lists.ItemDivider()
104110
Setting.Text(
@@ -278,5 +284,5 @@ fun SettingsPreview() {
278284
vm.tailNetLockEnabled.set(true)
279285
vm.isAdmin.set(true)
280286
vm.managedByOrganization.set("Tails and Scales Inc.")
281-
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
287+
SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
282288
}

0 commit comments

Comments
 (0)