diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index 481e85de79..3514ec7457 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -426,7 +426,11 @@ open class UninitializedApp : Application() { // the VPN (i.e. we're logged in and machine is authorized). private const val ABLE_TO_START_VPN_KEY = "ableToStartVPN" private const val DISALLOWED_APPS_KEY = "disallowedApps" - // File for shared preferences that are not encrypted. + private const val ALLOWED_APPS_KEY = "allowedApps" + private const val SPLIT_TUNNEL_KEY = "splitTunnelEnabled" + private const val SPLIT_TUNNEL_MODE_KEY = "split_tunnel_mode" + + // File for shared preferences that are not encrypted. private const val UNENCRYPTED_PREFERENCES = "unencrypted" private lateinit var appInstance: UninitializedApp lateinit var notificationManager: NotificationManagerCompat @@ -599,19 +603,73 @@ open class UninitializedApp : Application() { this.restartVPN() } - fun disallowedPackageNames(): List { + fun updateUserAllowedPackageNames(packageNames: List) { + if (packageNames.any { it.isEmpty() }) { + TSLog.e(TAG, "updateUserAllowedPackageNames called with empty packageName(s)") + return + } + getUnencryptedPrefs().edit().putStringSet(ALLOWED_APPS_KEY, packageNames.toSet()).apply() + this.restartVPN() + } + + fun isSplitTunnelEnabled(): Boolean = + getUnencryptedPrefs().getBoolean(SPLIT_TUNNEL_KEY, false) + + fun setSplitTunnelEnabled(enabled: Boolean) { + getUnencryptedPrefs().edit().putBoolean(SPLIT_TUNNEL_KEY, enabled).apply() + restartVPN() + } + + fun setSplitTunnelMode(mode: SplitTunnelMode) { + getUnencryptedPrefs().edit() + .putString(SPLIT_TUNNEL_MODE_KEY, mode.name) + .apply() + restartVPN() + } + + fun getSplitTunnelMode(): SplitTunnelMode { + val stored = getUnencryptedPrefs().getString(SPLIT_TUNNEL_MODE_KEY, null) + return if (stored != null) { + SplitTunnelMode.valueOf(stored) + } else { + SplitTunnelMode.EXCLUDE + } + } + + + + + fun disallowedPackageNames(): List { val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() - if (mdmDisallowed.isNotEmpty()) { + + if (mdmDisallowed.isNotEmpty()) { TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") return builtInDisallowedPackageNames + mdmDisallowed } + val userDisallowed = getUnencryptedPrefs().getStringSet(DISALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() return builtInDisallowedPackageNames + userDisallowed } - fun getAppScopedViewModel(): AppViewModel { + + fun allowedPackageNames(): List { + val mdmAllowed = + MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() + + if (mdmAllowed.isNotEmpty()) { + TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed") + return mdmAllowed + } + + val userAllowed = + getUnencryptedPrefs().getStringSet(ALLOWED_APPS_KEY, emptySet())?.toList() ?: emptyList() + return userAllowed + } + + + fun getAppScopedViewModel(): AppViewModel { return appViewModel } @@ -640,4 +698,9 @@ open class UninitializedApp : Application() { // Android Connectivity Service https://github.com/tailscale/tailscale/issues/14128 "com.google.android.apps.scone", ) + + enum class SplitTunnelMode { + INCLUDE, + EXCLUDE + } } diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index e6eb995d71..f90bacb702 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -149,39 +149,82 @@ open class IPNService : VpnService(), libtailscale.IPNService { } } - override fun newBuilder(): VPNServiceBuilder { - val b: Builder = - Builder() - .setConfigureIntent(configIntent()) - .allowFamily(OsConstants.AF_INET) - .allowFamily(OsConstants.AF_INET6) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - b.setMetered(false) // Inherit the metered status from the underlying networks. - } - b.setUnderlyingNetworks(null) // Use all available networks. - - val includedPackages: List = - MDMSettings.includedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() - if (includedPackages.isNotEmpty()) { - // If an admin defined a list of packages that are exclusively allowed to be used via - // Tailscale, - // then only allow those apps. - for (packageName in includedPackages) { - TSLog.d(TAG, "Including app: $packageName") - b.addAllowedApplication(packageName) - } - } else { - // Otherwise, prevent certain apps from getting their traffic + DNS routed via Tailscale: - // - any app that the user manually disallowed in the GUI - // - any app that we disallowed via hard-coding - for (disallowedPackageName in UninitializedApp.get().disallowedPackageNames()) { - TSLog.d(TAG, "Disallowing app: $disallowedPackageName") - disallowApp(b, disallowedPackageName) - } + private fun allowApp(b: Builder, name: String) { + try { + b.addAllowedApplication(name) + } catch (e: PackageManager.NameNotFoundException) { + TSLog.d(TAG, "Failed to add allowed application: $e") + } } - return VPNServiceBuilder(b) - } + override fun newBuilder(): VPNServiceBuilder { + val b: Builder = + Builder() + .setConfigureIntent(configIntent()) + .allowFamily(OsConstants.AF_INET) + .allowFamily(OsConstants.AF_INET6) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + b.setMetered(false) // Inherit the metered status from the underlying networks. + } + b.setUnderlyingNetworks(null) // Use all available networks. + + val app = UninitializedApp.get() + + val mdmAllowed = MDMSettings.includedPackages.flow.value.value?. + split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + val mdmDisallowed = MDMSettings.excludedPackages.flow.value.value?. + split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + + val splitEnabled = app.isSplitTunnelEnabled() + val mode = app.getSplitTunnelMode() + + when { + mdmAllowed.isNotEmpty() -> { + TSLog.d(TAG, "MDM include mode, allowed = $mdmAllowed") + mdmAllowed.forEach { pkg -> + TSLog.d(TAG, "Including app via MDM: $pkg") + allowApp(b, pkg) + } + } + + mdmDisallowed.isNotEmpty() -> { + val effectiveDisallowed = app.builtInDisallowedPackageNames + mdmDisallowed + TSLog.d(TAG, "MDM exclude mode, disallowed = $effectiveDisallowed") + effectiveDisallowed.forEach { pkg -> + TSLog.d(TAG, "Disallowing app via MDM: $pkg") + disallowApp(b, pkg) + } + } + + !splitEnabled -> { + TSLog.d(TAG, "Split tunneling disabled; using built-in disallowed only") + app.builtInDisallowedPackageNames.forEach { pkg -> + TSLog.d(TAG, "Disallowing built-in app: $pkg") + disallowApp(b, pkg) + } + } + + mode == UninitializedApp.SplitTunnelMode.INCLUDE -> { + val userAllowed = app.allowedPackageNames() + TSLog.d(TAG, "User INCLUDE mode; allowed = $userAllowed") + userAllowed.forEach { pkg -> + TSLog.d(TAG, "Including app via user INCLUDE: $pkg") + allowApp(b, pkg) + } + } + + else -> { + val effectiveDisallowed = app.disallowedPackageNames() + TSLog.d(TAG, "User EXCLUDE mode; disallowed = $effectiveDisallowed") + effectiveDisallowed.forEach { pkg -> + TSLog.d(TAG, "Disallowing app via user EXCLUDE/built-in: $pkg") + disallowApp(b, pkg) + } + } + } + + return VPNServiceBuilder(b) + } companion object { const val ACTION_START_VPN = "com.tailscale.ipn.START_VPN" diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 38acc7fac9..bc8213bd99 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -120,6 +120,7 @@ class Ipn { CorpDNSSet = true } + var ExitNodeID: StableNodeID? = null set(value) { field = value 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 2dc187fc26..c368eb8f11 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 @@ -89,7 +89,7 @@ fun SettingsView( Lists.ItemDivider() Setting.Text( R.string.split_tunneling, - subtitle = stringResource(R.string.exclude_certain_apps_from_using_tailscale), + subtitle = stringResource(R.string.include_or_exclude_certain_apps_from_using_tailscale), onClick = settingsNav.onNavigateToSplitTunneling) if (showTailnetLock.value == ShowHide.Show) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt index 27b18c5dac..7487c5cd3c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SplitTunnelAppPickerView.kt @@ -4,19 +4,35 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text 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.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.res.stringResource @@ -26,6 +42,7 @@ import androidx.core.graphics.drawable.toBitmap import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.App import com.tailscale.ipn.R +import com.tailscale.ipn.UninitializedApp.SplitTunnelMode import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel @@ -36,76 +53,287 @@ fun SplitTunnelAppPickerView( ) { val installedApps by model.installedApps.collectAsState() val excludedPackageNames by model.excludedPackageNames.collectAsState() - val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames + val includedPackageNames by model.includedPackageNames.collectAsState() + + + val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState() val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState() - Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { + val splitEnabled = remember { mutableStateOf(App.get().isSplitTunnelEnabled())} + val currentSplitMode = remember { mutableStateOf(App.get().getSplitTunnelMode())} + val searchQuery = remember { mutableStateOf("") } + + val filteredApps = installedApps.filter { app -> + searchQuery.value.isBlank() || + app.name.contains(searchQuery.value, ignoreCase = true) || + app.packageName.contains(searchQuery.value, ignoreCase = true) + } + + + + + Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { item(key = "header") { - ListItem( + + val mdmActive = + (mdmExcludedPackages.value?.isNotEmpty() == true) || + (mdmIncludedPackages.value?.isNotEmpty() == true) + + + if (mdmActive) { + Setting.Switch( + R.string.split_tunneling_enabled, + isOn = true, + enabled = false, + onToggle = {} + ) + } else { + + Setting.Switch( + R.string.split_tunneling_enabled, + isOn = splitEnabled.value, + onToggle = { + val newVal = !App.get().isSplitTunnelEnabled() + App.get().setSplitTunnelEnabled(newVal) + splitEnabled.value = App.get().isSplitTunnelEnabled() + } + ) + } + + ListItem( headlineContent = { Text( stringResource( R.string - .selected_apps_will_access_the_internet_directly_without_using_tailscale)) + .selected_apps_will_follow_custom_routing)) }) + } - if (mdmExcludedPackages.value?.isNotEmpty() == true) { - item("mdmExcludedNotice") { - ListItem( - headlineContent = { - Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) - }) - } - } else if (mdmIncludedPackages.value?.isNotEmpty() == true) { - item("mdmIncludedNotice") { - ListItem( - headlineContent = { - Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale)) - }) - } - } else { - item("resolversHeader") { - Lists.SectionDivider( - stringResource(R.string.count_excluded_apps, excludedPackageNames.count())) - } - items(installedApps) { app -> - ListItem( - headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, - leadingContent = { - Image( - bitmap = - model.installedAppsManager.packageManager - .getApplicationIcon(app.packageName) - .toBitmap() - .asImageBitmap(), - contentDescription = null, - modifier = Modifier.width(40.dp).height(40.dp)) - }, - supportingContent = { - Text( - app.packageName, - color = MaterialTheme.colorScheme.secondary, - fontSize = MaterialTheme.typography.bodySmall.fontSize, - letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing) - }, - trailingContent = { - Checkbox( - checked = excludedPackageNames.contains(app.packageName), - enabled = !builtInDisallowedPackageNames.contains(app.packageName), - onCheckedChange = { checked -> - if (checked) { - model.exclude(packageName = app.packageName) - } else { - model.unexclude(packageName = app.packageName) - } + + + if (mdmExcludedPackages.value?.isNotEmpty() == true) { + item("mdmExcludedNotice") { + ListItem( + headlineContent = { + Text(stringResource(R.string.certain_apps_are_not_routed_via_tailscale)) + }) + } + } else if (mdmIncludedPackages.value?.isNotEmpty() == true) { + item("mdmIncludedNotice") { + ListItem( + headlineContent = { + Text(stringResource(R.string.only_specific_apps_are_routed_via_tailscale)) }) - }) - Lists.ItemDivider() + } + } else { + if (splitEnabled.value) { + + item("resolversHeader") { + + Spacer(modifier = Modifier.height(8.dp)) + + + AppSearchBar( + query = searchQuery.value, + onQueryChange = { searchQuery.value = it } + ) + + Row(modifier = Modifier.padding(horizontal = 8.dp)) { + FilterChip( + selected = currentSplitMode.value == SplitTunnelMode.EXCLUDE, + onClick = { + App.get().setSplitTunnelMode(SplitTunnelMode.EXCLUDE) + currentSplitMode.value = App.get().getSplitTunnelMode() + }, + label = { Text("Exclude apps") } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + + FilterChip( + selected = currentSplitMode.value == SplitTunnelMode.INCLUDE, + onClick = { + App.get().setSplitTunnelMode(SplitTunnelMode.INCLUDE) + currentSplitMode.value = App.get().getSplitTunnelMode() + }, + label = { Text("Include apps") } + ) + } + } + + + + if (currentSplitMode.value == SplitTunnelMode.EXCLUDE) { + item("resolversHeaderExclude") { + Lists.SectionDivider( + stringResource( + R.string.count_excluded_apps, + excludedPackageNames.count() + ) + ) + } + + if (filteredApps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 24.dp, horizontal = 16.dp) + ) { + Text( + "No apps found", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + items(filteredApps) { app -> + ListItem( + headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, + leadingContent = { + Image( + bitmap = + model.installedAppsManager.packageManager + .getApplicationIcon(app.packageName) + .toBitmap() + .asImageBitmap(), + contentDescription = null, + modifier = Modifier.width(40.dp).height(40.dp) + ) + }, + supportingContent = { + Text( + app.packageName, + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing + ) + }, + trailingContent = { + Checkbox( + checked = excludedPackageNames.contains(app.packageName), + enabled = !builtInDisallowedPackageNames.contains(app.packageName), + onCheckedChange = { checked -> + if (checked) { + model.exclude(packageName = app.packageName) + } else { + model.unexclude(packageName = app.packageName) + } + }) + }) + Lists.ItemDivider() + } + } else { + + item("resolversHeaderInclude") { + Lists.SectionDivider( + stringResource( + R.string.count_included_apps, + includedPackageNames.count() + ) + ) + } + + if (filteredApps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(vertical = 24.dp, horizontal = 16.dp) + ) { + Text( + "No apps found", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + + items(filteredApps) { app -> + ListItem( + headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, + leadingContent = { + Image( + bitmap = + model.installedAppsManager.packageManager + .getApplicationIcon(app.packageName) + .toBitmap() + .asImageBitmap(), + contentDescription = null, + modifier = Modifier.width(40.dp).height(40.dp) + ) + }, + supportingContent = { + Text( + app.packageName, + color = MaterialTheme.colorScheme.secondary, + fontSize = MaterialTheme.typography.bodySmall.fontSize, + letterSpacing = MaterialTheme.typography.bodySmall.letterSpacing + ) + }, + trailingContent = { + Checkbox( + checked = includedPackageNames.contains(app.packageName), + onCheckedChange = { checked -> + if (checked) { + model.include(packageName = app.packageName) + } else { + model.uninclude(packageName = app.packageName) + } + }) + }) + Lists.ItemDivider() + + + } + + + + } + } } - } } } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSearchBar( + query: String, + onQueryChange: (String) -> Unit +) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp) + .background(MaterialTheme.colorScheme.surfaceContainer), + value = query, + onValueChange = onQueryChange, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search" + ) + }, + shape = SearchBarDefaults.dockedShape, + placeholder = { Text(stringResource(R.string.search_apps_ellipsis)) }, + singleLine = true, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + } + ) +} + + diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt index 7611f0516e..4ebb2a3166 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt @@ -20,12 +20,16 @@ import kotlinx.coroutines.launch class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) val excludedPackageNames: StateFlow> = MutableStateFlow(listOf()) + val includedPackageNames: StateFlow> = MutableStateFlow(listOf()) val installedApps: StateFlow> = MutableStateFlow(listOf()) val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow private var saveJob: Job? = null + + + init { installedApps.set(installedAppsManager.fetchInstalledApps()) excludedPackageNames.set( @@ -33,6 +37,12 @@ class SplitTunnelAppPickerViewModel : ViewModel() { .disallowedPackageNames() .intersect(installedApps.value.map { it.packageName }.toSet()) .toList()) + includedPackageNames.set( + App.get() + .allowedPackageNames() + .intersect(installedApps.value.map { it.packageName }.toSet()) + .toList()) + } fun exclude(packageName: String) { @@ -46,7 +56,18 @@ class SplitTunnelAppPickerViewModel : ViewModel() { debounceSave() } - private fun debounceSave() { + fun include(packageName: String) { + if (includedPackageNames.value.contains(packageName)) return + includedPackageNames.set(includedPackageNames.value + packageName) + debounceSaveInclude() + } + + fun uninclude(packageName: String) { + includedPackageNames.set(includedPackageNames.value - packageName) + debounceSaveInclude() + } + + private fun debounceSave() { saveJob?.cancel() saveJob = viewModelScope.launch { @@ -54,4 +75,32 @@ class SplitTunnelAppPickerViewModel : ViewModel() { App.get().updateUserDisallowedPackageNames(excludedPackageNames.value) } } + + private fun debounceSaveInclude() { + saveJob?.cancel() + saveJob = + viewModelScope.launch { + delay(500) + App.get().updateUserAllowedPackageNames(includedPackageNames.value) + } + } + + + fun toggleSplitTunnel() { + val newValue = !App.get().isSplitTunnelEnabled() + App.get().setSplitTunnelEnabled(newValue) + } + + // If MDM inforces split tunnel — write it to sharedprefs + private fun enforceMdMSplitTunnel() { + val mdmActive = + mdmExcludedPackages.value.value?.isNotEmpty() == true || + mdmIncludedPackages.value.value?.isNotEmpty() == true + + if (mdmActive && !App.get().isSplitTunnelEnabled()) { + App.get().setSplitTunnelEnabled(true) + } + } + + } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 3e3db0512c..3d00045c18 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -20,6 +20,7 @@ Warning Search Search... + Search apps... Dismiss No results Back @@ -309,9 +310,11 @@ An unknown error occurred. Please try again. Request timed out. Make sure that \'%1$s\' is online. App split tunneling - Exclude certain apps from using Tailscale - Apps selected here will access the Internet directly, without using Tailscale. + Enable split tunneling + Include or exclude certain apps from using Tailscale + Apps selected here will follow special routing depending on your split-tunnel configuration. Excluded apps (%1$s) + Included apps (%1$s) Certain apps are not routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator. Only specific apps are routed via Tailscale on this device. This setting is managed by your organization and cannot be changed by you. For more information, contact your network administrator. Specifies a list of apps that will be excluded from Tailscale routes and DNS even when Tailscale is running. All other apps will use Tailscale.