diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 9633e2b2bd..73fa2cc92d 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
{
+ return getUnencryptedPrefs().getStringSet(WIFI_WHITELIST_SSIDS_KEY, emptySet()) ?: emptySet()
+ }
+
+ fun setWhitelistSsids(ssids: Set) {
+ getUnencryptedPrefs().edit().putStringSet(WIFI_WHITELIST_SSIDS_KEY, ssids).apply()
+ }
+
+ // Blacklist = unsafe networks where VPN is always started automatically.
+ fun getBlacklistSsids(): Set {
+ return getUnencryptedPrefs().getStringSet(WIFI_BLACKLIST_SSIDS_KEY, emptySet()) ?: emptySet()
+ }
+
+ fun setBlacklistSsids(ssids: Set) {
+ getUnencryptedPrefs().edit().putStringSet(WIFI_BLACKLIST_SSIDS_KEY, ssids).apply()
+ }
+
+ fun getWifiAutoConnectDefaultOn(): Boolean {
+ return getUnencryptedPrefs().getBoolean(WIFI_AUTO_CONNECT_DEFAULT_ON_KEY, false)
+ }
+
+ fun setWifiAutoConnectDefaultOn(enabled: Boolean) {
+ getUnencryptedPrefs().edit().putBoolean(WIFI_AUTO_CONNECT_DEFAULT_ON_KEY, enabled).apply()
+ }
+
val builtInDisallowedPackageNames: List =
listOf(
// RCS/Jibe https://github.com/tailscale/tailscale/issues/2322
diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt
index a338dc8011..5f1ba4372a 100644
--- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt
+++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt
@@ -88,6 +88,7 @@ import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
+import com.tailscale.ipn.ui.view.WifiAutoConnectView
import com.tailscale.ipn.ui.view.SubnetRoutingView
import com.tailscale.ipn.ui.view.TaildropDirView
import com.tailscale.ipn.ui.view.TaildropDirectoryPickerPrompt
@@ -310,6 +311,9 @@ class MainActivity : ComponentActivity() {
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onNavigateToPermissions = { navController.navigate("permissions") },
+ onNavigateToWifiAutoConnect = {
+ navController.navigate("wifiAutoConnect")
+ },
onBackToSettings = backTo("settings"),
onNavigateBackHome = backTo("main"))
val exitNodePickerNav =
@@ -373,6 +377,7 @@ class MainActivity : ComponentActivity() {
composable("bugReport") { BugReportView(backTo("settings")) }
composable("dnsSettings") { DNSSettingsView(backTo("settings")) }
composable("splitTunneling") { SplitTunnelAppPickerView(backTo("settings")) }
+ composable("wifiAutoConnect") { WifiAutoConnectView(backTo("settings")) }
composable("tailnetLock") { TailnetLockSetupView(backTo("settings")) }
composable("subnetRouting") { SubnetRoutingView(backTo("settings")) }
composable("about") { AboutView(backTo("settings")) }
diff --git a/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt b/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt
index bf8ddd60df..90ab89c97f 100644
--- a/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt
+++ b/android/src/main/java/com/tailscale/ipn/NetworkChangeCallback.kt
@@ -2,12 +2,19 @@
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn
+import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import android.os.Build
import android.util.Log
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.WorkManager
import com.tailscale.ipn.util.TSLog
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -37,6 +44,22 @@ object NetworkChangeCallback {
var cachedDefaultInterfaceName: String? = null
private set
+ // Networks where we auto-started VPN (blacklist or defaultOn). Tracked to auto-stop on leave.
+ private val startedForNetworks = mutableMapOf()
+
+ // Networks where we auto-stopped VPN (whitelist). Tracked to restart VPN on leave if defaultOn.
+ private val stoppedForNetworks = mutableMapOf()
+
+ // User manually stopped VPN on a blacklist/defaultOn network → suppress re-auto-start.
+ private val userStoppedOnNetworks = mutableSetOf()
+
+ // User manually started VPN on a whitelist network → suppress auto-stop.
+ private val userStartedOnWhitelist = mutableSetOf()
+
+ // Stored for checkExistingNetworks().
+ private var wifiAutoConnectivity: ConnectivityManager? = null
+ private var wifiAutoConnectApp: UninitializedApp? = null
+
// monitorDnsChanges sets up a network callback to monitor changes to the
// system's network state and update the DNS configuration when interfaces
// become available or properties of those interfaces change.
@@ -208,4 +231,154 @@ object NetworkChangeCallback {
Libtailscale.onDNSConfigChanged(info.linkProps.interfaceName)
}
}
+
+ // userStoppedVpn: user manually stopped VPN → suppress re-auto-start on current networks.
+ fun userStoppedVpn() {
+ lock.withLock { userStoppedOnNetworks.addAll(startedForNetworks.keys) }
+ }
+
+ // userStartedVpn: user manually started VPN → suppress auto-stop on whitelist networks.
+ fun userStartedVpn() {
+ lock.withLock { userStartedOnWhitelist.addAll(stoppedForNetworks.keys) }
+ }
+
+ // monitorWifiAutoConnect registers a callback to auto-start the VPN on trusted SSIDs and
+ // auto-stop it when leaving those networks.
+ fun monitorWifiAutoConnect(connectivityManager: ConnectivityManager, app: UninitializedApp) {
+ wifiAutoConnectivity = connectivityManager
+ wifiAutoConnectApp = app
+ val request =
+ NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
+ .build()
+
+ connectivityManager.registerNetworkCallback(
+ request,
+ object : ConnectivityManager.NetworkCallback() {
+ override fun onCapabilitiesChanged(
+ network: Network,
+ capabilities: NetworkCapabilities
+ ) {
+ super.onCapabilitiesChanged(network, capabilities)
+ if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return
+ val ssid = getSsidFromCaps(capabilities, app) ?: return
+ var action: (() -> Unit)? = null
+ lock.withLock {
+ val whitelist = app.getWhitelistSsids()
+ val blacklist = app.getBlacklistSsids()
+ val defaultOn = app.getWifiAutoConnectDefaultOn()
+ val isWhitelisted = whitelist.contains(ssid)
+ val isBlacklisted = blacklist.contains(ssid)
+ TSLog.d(TAG, "wifi: ssid=$ssid whitelist=$whitelist blacklist=$blacklist defaultOn=$defaultOn")
+ when {
+ isWhitelisted -> {
+ if (network in userStartedOnWhitelist) return@withLock
+ if (network in stoppedForNetworks) return@withLock
+ TSLog.d(TAG, "wifi: whitelist → stopping VPN")
+ stoppedForNetworks[network] = ssid
+ action = {
+ WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
+ app.stopVPN()
+ }
+ }
+ isBlacklisted || defaultOn -> {
+ if (network in userStoppedOnNetworks) return@withLock
+ if (network in startedForNetworks) return@withLock
+ startedForNetworks[network] = ssid
+ if (app.isAbleToStartVPN()) {
+ TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → starting VPN")
+ action = { enqueueVpnStart(app) }
+ } else {
+ TSLog.d(TAG, "wifi: ${if (isBlacklisted) "blacklist" else "defaultOn"} → not yet authenticated, tracking network")
+ }
+ }
+ else -> TSLog.d(TAG, "wifi: unknown network, defaultOn=false → no action")
+ }
+ }
+ action?.invoke()
+ }
+
+ override fun onLost(network: Network) {
+ super.onLost(network)
+ var action: (() -> Unit)? = null
+ lock.withLock {
+ val startedSsid = startedForNetworks.remove(network)
+ val stoppedSsid = stoppedForNetworks.remove(network)
+ userStoppedOnNetworks.remove(network)
+ userStartedOnWhitelist.remove(network)
+ if (startedSsid != null) {
+ TSLog.d(TAG, "wifi: left blacklist/defaultOn network $startedSsid → stopping VPN")
+ action = {
+ WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
+ app.stopVPN()
+ }
+ } else if (stoppedSsid != null && app.getWifiAutoConnectDefaultOn()) {
+ TSLog.d(TAG, "wifi: left whitelist network $stoppedSsid, defaultOn=true → starting VPN")
+ action = { enqueueVpnStart(app) }
+ }
+ }
+ action?.invoke()
+ }
+ })
+ }
+
+ // checkExistingNetworks re-evaluates all active WiFi networks against current lists.
+ // Call when whitelist, blacklist, or defaultOn setting changes.
+ fun checkExistingNetworks() {
+ wifiAutoConnectivity ?: return
+ val app = wifiAutoConnectApp ?: return
+ val actions = mutableListOf<() -> Unit>()
+ lock.withLock {
+ val whitelist = app.getWhitelistSsids()
+ val blacklist = app.getBlacklistSsids()
+ val defaultOn = app.getWifiAutoConnectDefaultOn()
+ // Clean stale entries for SSIDs removed from their respective lists.
+ if (!defaultOn) startedForNetworks.entries.removeIf { (_, ssid) -> !blacklist.contains(ssid) }
+ stoppedForNetworks.entries.removeIf { (_, ssid) -> !whitelist.contains(ssid) }
+ for ((network, info) in activeNetworks) {
+ if (!info.caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) continue
+ val ssid = getSsidFromCaps(info.caps, app) ?: continue
+ val isWhitelisted = whitelist.contains(ssid)
+ val isBlacklisted = blacklist.contains(ssid)
+ when {
+ isWhitelisted -> {
+ if (network in userStartedOnWhitelist) continue
+ if (network in stoppedForNetworks) continue
+ stoppedForNetworks[network] = ssid
+ actions += {
+ WorkManager.getInstance(app).cancelUniqueWork("wifi_auto_connect")
+ app.stopVPN()
+ }
+ }
+ isBlacklisted || defaultOn -> {
+ if (network in userStoppedOnNetworks) continue
+ if (network in startedForNetworks) continue
+ startedForNetworks[network] = ssid
+ if (app.isAbleToStartVPN()) actions += { enqueueVpnStart(app) }
+ }
+ }
+ }
+ }
+ actions.forEach { it() }
+ }
+
+ private fun String?.cleanSsid(): String? =
+ this?.removePrefix("\"")?.removeSuffix("\"")?.takeIf { it.isNotEmpty() && it != "" }
+
+ private fun getSsidFromCaps(capabilities: NetworkCapabilities, app: UninitializedApp): String? {
+ // Try transportInfo first (preferred on API 29+, no location perm needed on API 31+).
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val fromTransport = (capabilities.transportInfo as? WifiInfo)?.ssid.cleanSsid()
+ if (fromTransport != null) return fromTransport
+ }
+ // Fallback: WifiManager (works if ACCESS_FINE_LOCATION or NEARBY_WIFI_DEVICES is granted).
+ @Suppress("DEPRECATION")
+ return (app.getSystemService(Context.WIFI_SERVICE) as? WifiManager)?.connectionInfo?.ssid.cleanSsid()
+ }
+
+ private fun enqueueVpnStart(app: UninitializedApp) {
+ val req = OneTimeWorkRequest.Builder(StartVPNWorker::class.java).build()
+ WorkManager.getInstance(app).enqueueUniqueWork("wifi_auto_connect", ExistingWorkPolicy.KEEP, req)
+ }
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt
index 64adf6392a..aa273a2cbd 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/SettingsView.kt
@@ -99,6 +99,12 @@ fun SettingsView(
subtitle = stringResource(R.string.filter_apps_allowed_to_access_tailscale),
onClick = settingsNav.onNavigateToSplitTunneling)
+ Lists.ItemDivider()
+ Setting.Text(
+ R.string.wifi_auto_connect,
+ subtitle = stringResource(R.string.wifi_auto_connect_subtitle),
+ onClick = settingsNav.onNavigateToWifiAutoConnect)
+
if (showTailnetLock.value == ShowHide.Show) {
Lists.ItemDivider()
Setting.Text(
@@ -278,5 +284,5 @@ fun SettingsPreview() {
vm.tailNetLockEnabled.set(true)
vm.isAdmin.set(true)
vm.managedByOrganization.set("Tails and Scales Inc.")
- SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
+ SettingsView(SettingsNav({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}), vm)
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/WifiAutoConnectView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/WifiAutoConnectView.kt
new file mode 100644
index 0000000000..5faa95aff2
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/WifiAutoConnectView.kt
@@ -0,0 +1,213 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.view
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.net.wifi.WifiManager
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.tailscale.ipn.R
+import com.tailscale.ipn.ui.util.Lists
+import com.tailscale.ipn.ui.util.itemsWithDividers
+import com.tailscale.ipn.ui.viewModel.WifiAutoConnectViewModel
+
+private enum class AddTarget { WHITELIST, BLACKLIST }
+
+@Composable
+fun WifiAutoConnectView(
+ backToSettings: BackNavigation,
+ model: WifiAutoConnectViewModel = viewModel()
+) {
+ val context = LocalContext.current
+ val whitelistSsids by model.whitelistSsids.collectAsState()
+ val blacklistSsids by model.blacklistSsids.collectAsState()
+ val defaultOn by model.defaultOn.collectAsState()
+
+ var addTarget by remember { mutableStateOf(null) }
+ var ssidInput by remember { mutableStateOf("") }
+
+ var locationPermissionGranted by remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED)
+ }
+ val permissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ locationPermissionGranted = granted
+ }
+
+ @Suppress("DEPRECATION")
+ fun currentSsid(): String? =
+ if (locationPermissionGranted)
+ (context.getSystemService(android.content.Context.WIFI_SERVICE) as? WifiManager)
+ ?.connectionInfo
+ ?.ssid
+ ?.removePrefix("\"")
+ ?.removeSuffix("\"")
+ ?.takeIf { it.isNotEmpty() && it != "" }
+ else null
+
+ Scaffold(
+ topBar = { Header(titleRes = R.string.wifi_auto_connect, onBack = backToSettings) }) {
+ innerPadding ->
+ LazyColumn(modifier = Modifier.padding(innerPadding)) {
+
+ // Whitelist section
+ item("whitelistHeader") {
+ Lists.SectionDivider(stringResource(R.string.wifi_whitelist_title))
+ }
+ item("whitelistDesc") {
+ ListItem(headlineContent = { Text(stringResource(R.string.wifi_whitelist_description)) })
+ }
+ itemsWithDividers(whitelistSsids, key = { "w_$it" }) { ssid ->
+ ListItem(
+ headlineContent = { Text(ssid) },
+ trailingContent = {
+ IconButton(onClick = { model.removeFromWhitelist(ssid) }) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.wifi_remove_network))
+ }
+ })
+ }
+ item("addWhitelist") {
+ if (whitelistSsids.isNotEmpty()) Lists.ItemDivider()
+ ListItem(
+ modifier = Modifier.clickable { addTarget = AddTarget.WHITELIST },
+ headlineContent = { Text(stringResource(R.string.wifi_add_network)) },
+ leadingContent = { Icon(Icons.Default.Add, contentDescription = null) })
+ }
+
+ // Blacklist section
+ item("blacklistHeader") {
+ Lists.SectionDivider(stringResource(R.string.wifi_blacklist_title))
+ }
+ item("blacklistDesc") {
+ ListItem(headlineContent = { Text(stringResource(R.string.wifi_blacklist_description)) })
+ }
+ itemsWithDividers(blacklistSsids, key = { "b_$it" }) { ssid ->
+ ListItem(
+ headlineContent = { Text(ssid) },
+ trailingContent = {
+ IconButton(onClick = { model.removeFromBlacklist(ssid) }) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.wifi_remove_network))
+ }
+ })
+ }
+ item("addBlacklist") {
+ if (blacklistSsids.isNotEmpty()) Lists.ItemDivider()
+ ListItem(
+ modifier = Modifier.clickable { addTarget = AddTarget.BLACKLIST },
+ headlineContent = { Text(stringResource(R.string.wifi_add_network)) },
+ leadingContent = { Icon(Icons.Default.Add, contentDescription = null) })
+ }
+
+ // Default ON toggle
+ item("defaultToggle") {
+ Lists.SectionDivider(stringResource(R.string.wifi_unknown_networks_title))
+ Setting.Switch(
+ R.string.wifi_auto_connect_default_on,
+ subtitle = stringResource(R.string.wifi_auto_connect_default_on_subtitle),
+ isOn = defaultOn,
+ onToggle = { model.setDefaultOn(it) })
+ }
+
+ // Permission banner
+ if (!locationPermissionGranted) {
+ item("permissionBanner") {
+ ListItem(
+ modifier = Modifier.clickable {
+ permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
+ },
+ headlineContent = {
+ Text(stringResource(R.string.wifi_location_permission_rationale))
+ },
+ trailingContent = {
+ TextButton(
+ onClick = {
+ permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
+ }) {
+ Text(stringResource(R.string.wifi_grant_location_permission))
+ }
+ })
+ }
+ }
+ }
+ }
+
+ if (addTarget != null) {
+ val current = currentSsid()
+ AlertDialog(
+ onDismissRequest = {
+ addTarget = null
+ ssidInput = ""
+ },
+ title = {
+ Text(
+ stringResource(
+ if (addTarget == AddTarget.WHITELIST) R.string.wifi_whitelist_title
+ else R.string.wifi_blacklist_title))
+ },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = ssidInput,
+ onValueChange = { ssidInput = it },
+ placeholder = { Text(stringResource(R.string.wifi_add_network_hint)) },
+ singleLine = true)
+ if (current != null) {
+ TextButton(onClick = { ssidInput = current }) {
+ Text(stringResource(R.string.wifi_use_current_network, current))
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ enabled = ssidInput.isNotBlank(),
+ onClick = {
+ if (addTarget == AddTarget.WHITELIST) model.addToWhitelist(ssidInput)
+ else model.addToBlacklist(ssidInput)
+ addTarget = null
+ ssidInput = ""
+ }) {
+ Text(stringResource(R.string.wifi_add_confirm))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ addTarget = null
+ ssidInput = ""
+ }) {
+ Text(stringResource(R.string.cancel))
+ }
+ })
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
index 04bf0e4b9b..7c359e2318 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt
@@ -15,6 +15,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
+import com.tailscale.ipn.NetworkChangeCallback
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.model.Ipn
@@ -211,12 +212,14 @@ class MainViewModel(private val appViewModel: AppViewModel) : IpnViewModel() {
if (desiredState) {
// User wants to turn ON the VPN
+ NetworkChangeCallback.userStartedVpn()
when {
currentState != Ipn.State.Running -> showVPNPermissionLauncherIfUnauthorized()
}
} else {
// User wants to turn OFF the VPN
if (currentState == Ipn.State.Running) {
+ NetworkChangeCallback.userStoppedVpn()
stopVPN()
}
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt
index e0fbc912fc..8ff36e303b 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt
@@ -24,6 +24,7 @@ data class SettingsNav(
val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit,
val onNavigateToPermissions: () -> Unit,
+ val onNavigateToWifiAutoConnect: () -> Unit,
val onNavigateBackHome: () -> Unit,
val onBackToSettings: () -> Unit,
)
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/WifiAutoConnectViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/WifiAutoConnectViewModel.kt
new file mode 100644
index 0000000000..6c77764973
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/WifiAutoConnectViewModel.kt
@@ -0,0 +1,59 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.viewModel
+
+import androidx.lifecycle.ViewModel
+import com.tailscale.ipn.App
+import com.tailscale.ipn.NetworkChangeCallback
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class WifiAutoConnectViewModel : ViewModel() {
+ private val app = App.get()
+
+ private val _whitelistSsids = MutableStateFlow(app.getWhitelistSsids().sorted())
+ val whitelistSsids: StateFlow> = _whitelistSsids
+
+ private val _blacklistSsids = MutableStateFlow(app.getBlacklistSsids().sorted())
+ val blacklistSsids: StateFlow> = _blacklistSsids
+
+ private val _defaultOn = MutableStateFlow(app.getWifiAutoConnectDefaultOn())
+ val defaultOn: StateFlow = _defaultOn
+
+ fun addToWhitelist(ssid: String) {
+ val trimmed = ssid.trim()
+ if (trimmed.isEmpty()) return
+ mutateSsids(app::getWhitelistSsids, app::setWhitelistSsids, _whitelistSsids) { add(trimmed) }
+ }
+
+ fun removeFromWhitelist(ssid: String) =
+ mutateSsids(app::getWhitelistSsids, app::setWhitelistSsids, _whitelistSsids) { remove(ssid) }
+
+ fun addToBlacklist(ssid: String) {
+ val trimmed = ssid.trim()
+ if (trimmed.isEmpty()) return
+ mutateSsids(app::getBlacklistSsids, app::setBlacklistSsids, _blacklistSsids) { add(trimmed) }
+ }
+
+ fun removeFromBlacklist(ssid: String) =
+ mutateSsids(app::getBlacklistSsids, app::setBlacklistSsids, _blacklistSsids) { remove(ssid) }
+
+ private fun mutateSsids(
+ get: () -> Set,
+ set: (Set) -> Unit,
+ flow: MutableStateFlow>,
+ block: MutableSet.() -> Unit
+ ) {
+ val updated = get().toMutableSet().apply(block)
+ set(updated)
+ flow.value = updated.sorted()
+ NetworkChangeCallback.checkExistingNetworks()
+ }
+
+ fun setDefaultOn(on: Boolean) {
+ app.setWifiAutoConnectDefaultOn(on)
+ _defaultOn.value = on
+ NetworkChangeCallback.checkExistingNetworks()
+ }
+}
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index 608f1f2a2e..64a862fe1e 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -309,6 +309,22 @@
An unknown error occurred. Please try again.
Request timed out. Make sure that \'%1$s\' is online.
App split tunneling
+ Wi-Fi auto-connect
+ Manage VPN behavior per Wi-Fi network
+ Safe networks (VPN off)
+ VPN stops automatically when joining these networks, and restarts when leaving (if unknown-network auto-start is enabled).
+ Unsafe networks (VPN always on)
+ VPN starts automatically when joining these networks and stops when leaving.
+ Unknown networks
+ Auto-start VPN on unknown networks
+ VPN starts when joining any network not in the lists above. Stops when leaving.
+ Add network
+ Network name (SSID)
+ Remove network
+ Add
+ Grant location permission to auto-detect Wi-Fi network names. Tap to grant.
+ Grant
+ Use current: %1$s
Your current selection will be cleared.
Filter what apps are allowed to access Tailscale.
Apps you select will access the internet without using Tailscale.