From c0325be1ce3e581dc5e3dd9e51af43de248a0157 Mon Sep 17 00:00:00 2001 From: Marek Hajduczenia Date: Tue, 14 Apr 2026 18:36:13 -0600 Subject: [PATCH] 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 --- android/src/main/AndroidManifest.xml | 1 + .../src/main/java/com/tailscale/ipn/App.kt | 7 + .../java/com/tailscale/ipn/MainActivity.kt | 7 + .../ipn/autoconnect/NetworkWatcher.kt | 201 ++++++++++++++++ .../ipn/autoconnect/TrustedNetworks.kt | 36 +++ .../com/tailscale/ipn/ui/model/Permissions.kt | 5 + .../com/tailscale/ipn/ui/view/SettingsView.kt | 8 +- .../ipn/ui/view/TrustedNetworksView.kt | 218 ++++++++++++++++++ .../ipn/ui/viewModel/SettingsViewModel.kt | 1 + .../ui/viewModel/TrustedNetworksViewModel.kt | 84 +++++++ android/src/main/res/values/strings.xml | 3 + 11 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 android/src/main/java/com/tailscale/ipn/autoconnect/NetworkWatcher.kt create mode 100644 android/src/main/java/com/tailscale/ipn/autoconnect/TrustedNetworks.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/TrustedNetworksView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/TrustedNetworksViewModel.kt 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 @@ + 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.