diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml
index 92cb0dea41..9d9800e7d3 100644
--- a/android/src/main/AndroidManifest.xml
+++ b/android/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
" }
+ if (ssid != null) {
+ TSLog.d(TAG, "SSID from transportInfo: $ssid")
+ return ssid
+ }
+
+ // Method 2: WifiManager.connectionInfo (deprecated but works on some Android 12+ devices)
+ val wm = context.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ @Suppress("DEPRECATION")
+ ssid = wm.connectionInfo?.ssid?.trim('"')
+ ?.takeIf { it.isNotBlank() && it != "" }
+ if (ssid != null) {
+ TSLog.d(TAG, "SSID from WifiManager: $ssid")
+ return ssid
+ }
+
+ // Method 3: WifiManager.scanResults — match by BSSID from connectionInfo
+ @Suppress("DEPRECATION")
+ val bssid = wm.connectionInfo?.bssid
+ if (bssid != null) {
+ ssid = wm.scanResults
+ ?.firstOrNull { it.BSSID == bssid }
+ ?.SSID
+ ?.trim('"')
+ ?.takeIf { it.isNotBlank() }
+ if (ssid != null) {
+ TSLog.d(TAG, "SSID from scanResults: $ssid")
+ return ssid
+ }
+ }
+
+ TSLog.d(TAG, "Could not determine SSID")
+ return null
+ }
+
+ private fun handleWifi(caps: NetworkCapabilities) {
+ val ssid = getSsid(caps)
+
+ if (ssid == null) {
+ // SSID may not be available yet after Wi-Fi association — retry a few times
+ if (ssidRetryCount < MAX_SSID_RETRIES) {
+ ssidRetryCount++
+ TSLog.d(TAG, "SSID null on Wi-Fi, scheduling retry $ssidRetryCount/$MAX_SSID_RETRIES")
+ handler.postDelayed({ evaluateCurrent() }, SSID_RETRY_DELAY_MS)
+ }
+ return
+ }
+
+ ssidRetryCount = 0
+ val trusted = TrustedNetworks.load(context)
+ TSLog.d(TAG, "SSID='$ssid' trusted_list=$trusted match=${ssid in trusted}")
+ if (ssid in trusted) {
+ disableVpn()
+ } else {
+ enableVpn()
+ }
+ }
+
+ private fun enableVpn() {
+ if (lastAction == "enable") return
+ val state = Notifier.state.value
+ if (state == Ipn.State.Running) {
+ lastAction = "enable"
+ return
+ }
+ lastAction = "enable"
+ TSLog.d(TAG, "Enabling VPN (auto-vpn)")
+ App.get().startVPN()
+ }
+
+ private fun disableVpn() {
+ if (lastAction == "disable") return
+ val state = Notifier.state.value
+ if (state != Ipn.State.Running) {
+ lastAction = "disable"
+ return
+ }
+ lastAction = "disable"
+ TSLog.d(TAG, "Disabling VPN (trusted network)")
+ App.get().stopVPN()
+ }
+
+ fun reevaluate() {
+ lastAction = null
+ ssidRetryCount = 0
+ evaluateCurrent()
+ }
+
+ fun evaluateCurrent() {
+ if (!TrustedNetworks.isEnabled(context)) return
+
+ // Find the underlying Wi-Fi or cellular network, skipping VPN
+ var wifiCaps: NetworkCapabilities? = null
+ var hasCellular = false
+
+ for (network in cm.allNetworks) {
+ val caps = cm.getNetworkCapabilities(network) ?: continue
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) continue
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
+ wifiCaps = caps
+ break // Wi-Fi takes priority
+ }
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
+ hasCellular = true
+ }
+ }
+
+ TSLog.d(TAG, "evaluateCurrent: wifi=${wifiCaps != null} cellular=$hasCellular")
+
+ when {
+ wifiCaps != null -> handleWifi(wifiCaps)
+ hasCellular -> enableVpn()
+ else -> enableVpn()
+ }
+ }
+
+ companion object {
+ private const val TAG = "NetworkWatcher"
+ private const val DEBOUNCE_MS = 2000L
+ private const val SSID_RETRY_DELAY_MS = 3000L
+ private const val MAX_SSID_RETRIES = 5
+ }
+}
diff --git a/android/src/main/java/com/tailscale/ipn/autoconnect/TrustedNetworks.kt b/android/src/main/java/com/tailscale/ipn/autoconnect/TrustedNetworks.kt
new file mode 100644
index 0000000000..0b5d0097e0
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/autoconnect/TrustedNetworks.kt
@@ -0,0 +1,36 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.autoconnect
+
+import android.content.Context
+
+object TrustedNetworks {
+ private const val PREFS_NAME = "tailscale_auto"
+ private const val KEY_SSIDS = "trusted_ssids"
+ private const val KEY_ENABLED = "auto_vpn_enabled"
+
+ fun isEnabled(ctx: Context): Boolean =
+ ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .getBoolean(KEY_ENABLED, false)
+
+ fun setEnabled(ctx: Context, enabled: Boolean) =
+ ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putBoolean(KEY_ENABLED, enabled)
+ .apply()
+
+ fun load(ctx: Context): Set =
+ ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .getStringSet(KEY_SSIDS, emptySet()) ?: emptySet()
+
+ fun save(ctx: Context, ssids: Set) =
+ ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putStringSet(KEY_SSIDS, ssids)
+ .apply()
+
+ fun add(ctx: Context, ssid: String) = save(ctx, load(ctx) + ssid)
+
+ fun remove(ctx: Context, ssid: String) = save(ctx, load(ctx) - ssid)
+}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt
index 10e4367f51..a63c336803 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/model/Permissions.kt
@@ -79,6 +79,11 @@ object Permissions {
R.string.permission_post_notifications,
R.string.permission_post_notifications_needed))
}
+ result.add(
+ Permission(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ R.string.permission_location,
+ R.string.permission_location_needed))
result
}
}
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 2a3ab34f93..cc36c72306 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
@@ -111,6 +111,12 @@ fun SettingsView(
Setting.Text(R.string.permissions, onClick = settingsNav.onNavigateToPermissions)
}
+ Lists.ItemDivider()
+ Setting.Text(
+ R.string.auto_vpn,
+ subtitle = "Auto-toggle VPN based on network",
+ onClick = settingsNav.onNavigateToTrustedNetworks)
+
managedByOrganization.value?.let {
Lists.ItemDivider()
Setting.Text(
@@ -219,5 +225,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/TrustedNetworksView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/TrustedNetworksView.kt
new file mode 100644
index 0000000000..11a233ce01
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/TrustedNetworksView.kt
@@ -0,0 +1,218 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.view
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.tailscale.ipn.R
+import com.tailscale.ipn.ui.viewModel.TrustedNetworksViewModel
+
+@Composable
+fun TrustedNetworksView(
+ onNavigateBack: () -> Unit,
+ viewModel: TrustedNetworksViewModel = viewModel()
+) {
+ val enabled by viewModel.enabled.collectAsState()
+ val ssids by viewModel.trustedSsids.collectAsState()
+ val currentSsid by viewModel.currentSsid.collectAsState()
+ var manualInput by remember { mutableStateOf("") }
+
+ val context = LocalContext.current
+ var locationGranted by remember {
+ mutableStateOf(
+ ContextCompat.checkSelfPermission(
+ context, android.Manifest.permission.ACCESS_FINE_LOCATION) ==
+ android.content.pm.PackageManager.PERMISSION_GRANTED)
+ }
+ val permissionLauncher =
+ rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ locationGranted = granted
+ if (granted) viewModel.refreshCurrentSsid()
+ }
+
+ LaunchedEffect(enabled) {
+ if (enabled && !locationGranted) {
+ permissionLauncher.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ }
+
+ Scaffold(topBar = { Header(titleRes = R.string.auto_vpn, onBack = onNavigateBack) }) {
+ innerPadding ->
+ Column(
+ modifier =
+ Modifier.fillMaxSize()
+ .padding(innerPadding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState())) {
+ Text(
+ text =
+ "When enabled, Tailscale VPN will be disabled on trusted Wi-Fi networks " +
+ "and enabled automatically on untrusted Wi-Fi, cellular data, " +
+ "or when no Wi-Fi is connected.",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 16.dp))
+
+ // -- Feature toggle --
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically) {
+ Text("Enable Auto-VPN", style = MaterialTheme.typography.titleMedium)
+ Switch(checked = enabled, onCheckedChange = { viewModel.setEnabled(it) })
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ if (!enabled) {
+ Text(
+ text = "Enable Auto-VPN above to configure trusted networks.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ if (!locationGranted) {
+ Text(
+ text =
+ "Location permission is required to detect the current Wi-Fi network. " +
+ "Without it, VPN will stay enabled on all networks (fail-secure).",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(bottom = 8.dp))
+ OutlinedButton(
+ onClick = {
+ permissionLauncher.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
+ },
+ modifier = Modifier.fillMaxWidth()) {
+ Text("Grant location permission")
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // -- Current network shortcut --
+ currentSsid?.let { ssid ->
+ if (ssid !in ssids) {
+ OutlinedButton(
+ onClick = { viewModel.addSsid(ssid) }, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Trust current network: $ssid")
+ }
+ } else {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = "Current network is trusted: $ssid",
+ color = MaterialTheme.colorScheme.primary)
+ }
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // -- Manual SSID entry --
+ Text(
+ text = "Add a trusted network manually",
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(bottom = 4.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ OutlinedTextField(
+ value = manualInput,
+ onValueChange = { manualInput = it },
+ placeholder = { Text("Network SSID") },
+ singleLine = true,
+ modifier = Modifier.weight(1f))
+ Spacer(modifier = Modifier.width(8.dp))
+ IconButton(
+ onClick = {
+ val trimmed = manualInput.trim()
+ if (trimmed.isNotBlank()) {
+ viewModel.addSsid(trimmed)
+ manualInput = ""
+ }
+ }) {
+ Icon(Icons.Default.Add, contentDescription = "Add network")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // -- Trusted network list --
+ Text(
+ text = "Trusted Networks",
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(bottom = 8.dp))
+
+ if (ssids.isEmpty()) {
+ Text(
+ text = "No trusted networks yet. Add your home network above.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ } else {
+ ssids.sorted().forEach { ssid ->
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ Icons.Default.CheckCircle,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(ssid)
+ }
+ IconButton(onClick = { viewModel.removeSsid(ssid) }) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Remove $ssid",
+ tint = MaterialTheme.colorScheme.error)
+ }
+ }
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+ }
+}
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 b9343c96b5..7e8191c9c8 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
@@ -23,6 +23,7 @@ data class SettingsNav(
val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit,
val onNavigateToPermissions: () -> Unit,
+ val onNavigateToTrustedNetworks: () -> Unit,
val onNavigateBackHome: () -> Unit,
val onBackToSettings: () -> Unit,
)
diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/TrustedNetworksViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TrustedNetworksViewModel.kt
new file mode 100644
index 0000000000..6169e3207a
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/TrustedNetworksViewModel.kt
@@ -0,0 +1,84 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.viewModel
+
+import android.app.Application
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.wifi.WifiInfo
+import android.net.wifi.WifiManager
+import androidx.lifecycle.AndroidViewModel
+import com.tailscale.ipn.App
+import com.tailscale.ipn.autoconnect.TrustedNetworks
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+class TrustedNetworksViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val ctx = application.applicationContext
+
+ private val _enabled = MutableStateFlow(TrustedNetworks.isEnabled(ctx))
+ val enabled: StateFlow = _enabled.asStateFlow()
+
+ private val _trustedSsids = MutableStateFlow(TrustedNetworks.load(ctx))
+ val trustedSsids: StateFlow> = _trustedSsids.asStateFlow()
+
+ private val _currentSsid = MutableStateFlow(null)
+ val currentSsid: StateFlow = _currentSsid.asStateFlow()
+
+ init {
+ refreshCurrentSsid()
+ }
+
+ private fun reevaluate() {
+ App.get().networkWatcher.reevaluate()
+ }
+
+ fun setEnabled(enabled: Boolean) {
+ TrustedNetworks.setEnabled(ctx, enabled)
+ _enabled.value = enabled
+ reevaluate()
+ }
+
+ fun addSsid(ssid: String) {
+ TrustedNetworks.add(ctx, ssid)
+ _trustedSsids.value = TrustedNetworks.load(ctx)
+ reevaluate()
+ }
+
+ fun removeSsid(ssid: String) {
+ TrustedNetworks.remove(ctx, ssid)
+ _trustedSsids.value = TrustedNetworks.load(ctx)
+ reevaluate()
+ }
+
+ fun refreshCurrentSsid() {
+ val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = cm.activeNetwork ?: return
+ val caps = cm.getNetworkCapabilities(network) ?: return
+ if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
+ // Try transportInfo first (works on some Android versions)
+ var ssid =
+ (caps.transportInfo as? WifiInfo)
+ ?.ssid
+ ?.trim('"')
+ ?.takeIf { it.isNotBlank() && it != "" }
+
+ // Fallback to WifiManager
+ if (ssid == null) {
+ val wm = ctx.getSystemService(Context.WIFI_SERVICE) as WifiManager
+ @Suppress("DEPRECATION")
+ ssid =
+ wm.connectionInfo
+ ?.ssid
+ ?.trim('"')
+ ?.takeIf { it.isNotBlank() && it != "" }
+ }
+
+ _currentSsid.value = ssid
+ }
+ }
+}
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index f8f852a0bf..f286b4d337 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -242,6 +242,8 @@
We use storage in order to receive files with Taildrop.
Notifications
We use notifications to help you troubleshoot broken connections, or notify you before you need to reauthenticate to your network. Persistent status notifications are off by default and can be enabled in system settings.
+ Location
+ Required by Auto-VPN to detect the current Wi-Fi network name (SSID). Without this permission, VPN will stay enabled on all networks.
Go to notification settings
Persistent status notifications are off by default and can be enabled in system settings.
Taildrop directory
@@ -309,6 +311,7 @@
An unknown error occurred. Please try again.
Request timed out. Make sure that \'%1$s\' is online.
App split tunneling
+ Auto-VPN
Your current selection will be cleared.
Filter what apps are allowed to access Tailscale.
Apps you select will access the internet without using Tailscale.