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.