diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index a91325ea38..02867338ac 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -387,7 +387,13 @@ 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" + // The value is 'disallowedApps' as it used to represent + // only disallowed applications. This has been changed + // and allowing/disallowing is based on ALLOW_SELECTED_APPS_KEY + // + // The value is kept the same to not reset everyone's configuration + private const val SELECTED_APPS_KEY = "disallowedApps" + private const val ALLOW_SELECTED_APPS_KEY = "allowSelectedApps" // File for shared preferences that are not encrypted. private const val UNENCRYPTED_PREFERENCES = "unencrypted" @@ -569,47 +575,53 @@ open class UninitializedApp : Application() { return builder.build() } - fun addUserDisallowedPackageName(packageName: String) { + fun addUserSelectedPackage(packageName: String) { if (packageName.isEmpty()) { - TSLog.e(TAG, "addUserDisallowedPackageName called with empty packageName") + TSLog.e(TAG, "addUserSelectedPackage called with empty packageName") return } getUnencryptedPrefs() .edit() .putStringSet( - DISALLOWED_APPS_KEY, disallowedPackageNames().toMutableSet().union(setOf(packageName))) + SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().union(setOf(packageName))) .apply() this.restartVPN() } - fun removeUserDisallowedPackageName(packageName: String) { + fun removeUserSelectedPackage(packageName: String) { if (packageName.isEmpty()) { - TSLog.e(TAG, "removeUserDisallowedPackageName called with empty packageName") + TSLog.e(TAG, "removeUserSelectedPackage called with empty packageName") return } getUnencryptedPrefs() .edit() .putStringSet( - DISALLOWED_APPS_KEY, - disallowedPackageNames().toMutableSet().subtract(setOf(packageName))) + SELECTED_APPS_KEY, selectedPackageNames().toMutableSet().subtract(setOf(packageName))) .apply() this.restartVPN() } - fun disallowedPackageNames(): List { - val mdmDisallowed = - MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() - 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 switchUserSelectedPackages() { + getUnencryptedPrefs() + .edit() + .putBoolean(ALLOW_SELECTED_APPS_KEY, !allowSelectedPackages()) + .apply() + getUnencryptedPrefs().edit().putStringSet(SELECTED_APPS_KEY, setOf()).apply() + + this.restartVPN() + } + + fun selectedPackageNames(): List { + return getUnencryptedPrefs().getStringSet(SELECTED_APPS_KEY, emptySet())?.toList() + ?: emptyList() + } + + fun allowSelectedPackages(): Boolean { + return getUnencryptedPrefs().getBoolean(ALLOW_SELECTED_APPS_KEY, false) } fun getAppScopedViewModel(): VpnViewModel { diff --git a/android/src/main/java/com/tailscale/ipn/IPNService.kt b/android/src/main/java/com/tailscale/ipn/IPNService.kt index 917b405330..09b7682095 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNService.kt +++ b/android/src/main/java/com/tailscale/ipn/IPNService.kt @@ -135,11 +135,19 @@ open class IPNService : VpnService(), libtailscale.IPNService { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) } + private fun allowApp(b: Builder, name: String) { + try { + b.addAllowedApplication(name) + } catch (e: PackageManager.NameNotFoundException) { + TSLog.e(TAG, "Failed to add allowed application: $e") + } + } + private fun disallowApp(b: Builder, name: String) { try { b.addDisallowedApplication(name) } catch (e: PackageManager.NameNotFoundException) { - TSLog.d(TAG, "Failed to add disallowed application: $e") + TSLog.e(TAG, "Failed to add disallowed application: $e") } } @@ -154,23 +162,49 @@ open class IPNService : VpnService(), libtailscale.IPNService { } b.setUnderlyingNetworks(null) // Use all available networks. - val includedPackages: List = + val mdmAllowed = 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) { + val mdmDisallowed = + MDMSettings.excludedPackages.flow.value.value?.split(",")?.map { it.trim() } ?: emptyList() + + var packagesList: List + var allowPackages: Boolean + if (mdmAllowed.isNotEmpty()) { + // An admin defined a list of packages that are exclusively allowed to be used via + // Tailscale, so only allow those. + packagesList = mdmAllowed + allowPackages = true + TSLog.d(TAG, "Included application packages were set via MDM: $mdmAllowed") + } else if (mdmDisallowed.isNotEmpty()) { + // An admin defined a list of packages that are excluded from accessing Tailscale, + // so ignore user definitions and only exclude those + packagesList = mdmDisallowed + allowPackages = false + TSLog.d(TAG, "Excluded application packages were set via MDM: $mdmDisallowed") + } else { + // Otherwise, prevent user manually disallowed apps from getting their traffic + DNS routed + // via Tailscale + packagesList = UninitializedApp.get().selectedPackageNames() + allowPackages = UninitializedApp.get().allowSelectedPackages() + TSLog.d(TAG, "Application packages were set by user: $packagesList") + } + + if (allowPackages) { + // There always needs to be at least one allowed application for the VPN service to filter the + // traffic so add our own application by default to fulfill that requirement + packagesList += BuildConfig.APPLICATION_ID + + for (packageName in packagesList) { TSLog.d(TAG, "Including app: $packageName") - b.addAllowedApplication(packageName) + allowApp(b, 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) + // Make sure to also exclude hard-coded apps that are known to cause issues + packagesList += UninitializedApp.get().builtInDisallowedPackageNames + + for (packageName in packagesList) { + TSLog.d(TAG, "Disallowing app: $packageName") + disallowApp(b, packageName) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt b/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt index 8abb3b6220..f1bea2bbb5 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/InstalledAppsManager.kt @@ -6,6 +6,7 @@ package com.tailscale.ipn.ui.util import android.Manifest import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import com.tailscale.ipn.BuildConfig data class InstalledApp(val name: String, val packageName: String) @@ -26,7 +27,7 @@ class InstalledAppsManager( } private val appIsIncluded: (ApplicationInfo) -> Boolean = { app -> - app.packageName != "com.tailscale.ipn" && + app.packageName != BuildConfig.APPLICATION_ID && // Only show apps that can access the Internet packageManager.checkPermission(Manifest.permission.INTERNET, app.packageName) == PackageManager.PERMISSION_GRANTED diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt index 5f7113f9d5..73e74bdeaa 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt @@ -16,6 +16,8 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import com.tailscale.ipn.ui.theme.link +import com.tailscale.ipn.ui.theme.secondaryButton +import com.tailscale.ipn.ui.theme.warningButton @Composable fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { @@ -26,6 +28,28 @@ fun PrimaryActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> content = content) } +@Composable +fun WarningActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button( + onClick = onClick, + contentPadding = PaddingValues(vertical = 12.dp), + modifier = Modifier.fillMaxWidth(), + content = content, + colors = MaterialTheme.colorScheme.warningButton, + ) +} + +@Composable +fun DismissActionButton(onClick: () -> Unit, content: @Composable RowScope.() -> Unit) { + Button( + onClick = onClick, + contentPadding = PaddingValues(vertical = 12.dp), + modifier = Modifier.fillMaxWidth(), + content = content, + colors = MaterialTheme.colorScheme.secondaryButton, + ) +} + @Composable fun OpenURLButton(title: String, url: String) { val handler = LocalUriHandler.current 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 e29e9882af..82e16b5ab1 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.filter_apps_allowed_to_access_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..f7033f2b4d 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,12 +4,20 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row 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.MoreVert +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -27,6 +35,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.App import com.tailscale.ipn.R import com.tailscale.ipn.ui.util.Lists +import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel @Composable @@ -35,23 +44,39 @@ fun SplitTunnelAppPickerView( model: SplitTunnelAppPickerViewModel = viewModel() ) { val installedApps by model.installedApps.collectAsState() - val excludedPackageNames by model.excludedPackageNames.collectAsState() + val selectedPackageNames by model.selectedPackageNames.collectAsState() + val allowSelected by model.allowSelected.collectAsState() val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState() val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState() + val showHeaderMenu by model.showHeaderMenu.collectAsState() + val showSwitchDialog by model.showSwitchDialog.collectAsState() - Scaffold(topBar = { Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }) { - innerPadding -> - LazyColumn(modifier = Modifier.padding(innerPadding)) { - item(key = "header") { - ListItem( - headlineContent = { - Text( - stringResource( - R.string - .selected_apps_will_access_the_internet_directly_without_using_tailscale)) + if (showSwitchDialog) { + SwitchAlertDialog( + onConfirm = { + model.showSwitchDialog.set(false) + model.performSelectionSwitch() + }, + onDismiss = { model.showSwitchDialog.set(false) }) + } + + Scaffold( + topBar = { + Header( + titleRes = R.string.split_tunneling, + onBack = backToSettings, + actions = { + Row { + FusMenu(viewModel = model, onSwitchClick = { model.showSwitchDialog.set(true) }) + IconButton(onClick = { model.showHeaderMenu.set(!showHeaderMenu) }) { + Icon(Icons.Default.MoreVert, "menu") + } + } }) - } + }, + ) { innerPadding -> + LazyColumn(modifier = Modifier.padding(innerPadding)) { if (mdmExcludedPackages.value?.isNotEmpty() == true) { item("mdmExcludedNotice") { ListItem( @@ -67,9 +92,22 @@ fun SplitTunnelAppPickerView( }) } } else { + item("header") { + ListItem( + headlineContent = { + Text( + stringResource( + if (allowSelected) R.string.selected_apps_will_access_tailscale + else + R.string + .selected_apps_will_access_the_internet_directly_without_using_tailscale)) + }) + } item("resolversHeader") { Lists.SectionDivider( - stringResource(R.string.count_excluded_apps, excludedPackageNames.count())) + stringResource( + if (allowSelected) R.string.count_included_apps else R.string.count_excluded_apps, + selectedPackageNames.count())) } items(installedApps) { app -> ListItem( @@ -93,13 +131,13 @@ fun SplitTunnelAppPickerView( }, trailingContent = { Checkbox( - checked = excludedPackageNames.contains(app.packageName), + checked = selectedPackageNames.contains(app.packageName), enabled = !builtInDisallowedPackageNames.contains(app.packageName), onCheckedChange = { checked -> if (checked) { - model.exclude(packageName = app.packageName) + model.select(packageName = app.packageName) } else { - model.unexclude(packageName = app.packageName) + model.deselect(packageName = app.packageName) } }) }) @@ -109,3 +147,40 @@ fun SplitTunnelAppPickerView( } } } + +@Composable +fun FusMenu(viewModel: SplitTunnelAppPickerViewModel, onSwitchClick: (() -> Unit)) { + val expanded by viewModel.showHeaderMenu.collectAsState() + val allowSelected by viewModel.allowSelected.collectAsState() + + DropdownMenu( + expanded = expanded, + onDismissRequest = { viewModel.showHeaderMenu.set(false) }, + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { + MenuItem( + onClick = { + viewModel.showHeaderMenu.set(false) + onSwitchClick() + }, + text = + stringResource( + if (allowSelected) R.string.switch_to_select_to_exclude + else R.string.switch_to_select_to_include)) + } +} + +@Composable +fun SwitchAlertDialog(onConfirm: (() -> Unit), onDismiss: (() -> Unit)) { + AlertDialog( + title = { Text(text = stringResource(R.string.switch_warning_dialog_title)) }, + text = { Text(text = stringResource(R.string.switch_warning_dialog_description)) }, + onDismissRequest = onDismiss, + confirmButton = { + WarningActionButton(onClick = onConfirm) { + Text(text = stringResource(R.string.confirm_switch)) + } + }, + dismissButton = { + DismissActionButton(onClick = onDismiss) { Text(text = stringResource(R.string.cancel)) } + }) +} 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 d00efb6759..89fe839286 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 @@ -15,30 +15,53 @@ import kotlinx.coroutines.flow.StateFlow class SplitTunnelAppPickerViewModel : ViewModel() { val installedAppsManager = InstalledAppsManager(packageManager = App.get().packageManager) - val excludedPackageNames: StateFlow> = MutableStateFlow(listOf()) + val installedApps: StateFlow> = MutableStateFlow(listOf()) + val selectedPackageNames: StateFlow> = MutableStateFlow(listOf()) + + val allowSelected: StateFlow = MutableStateFlow(false) + val showHeaderMenu: StateFlow = MutableStateFlow(false) + val showSwitchDialog: StateFlow = MutableStateFlow(false) + val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow val mdmIncludedPackages: StateFlow> = MDMSettings.includedPackages.flow init { installedApps.set(installedAppsManager.fetchInstalledApps()) - excludedPackageNames.set( + initSelectedPackageNames() + } + + private fun initSelectedPackageNames() { + allowSelected.set(App.get().allowSelectedPackages()) + selectedPackageNames.set( App.get() - .disallowedPackageNames() + .selectedPackageNames() + .let { + if (!allowSelected.value) { + it.union(App.get().builtInDisallowedPackageNames) + } else { + it + } + } .intersect(installedApps.value.map { it.packageName }.toSet()) .toList()) } - fun exclude(packageName: String) { - if (excludedPackageNames.value.contains(packageName)) { + fun performSelectionSwitch() { + App.get().switchUserSelectedPackages() + initSelectedPackageNames() + } + + fun select(packageName: String) { + if (selectedPackageNames.value.contains(packageName)) { return } - excludedPackageNames.set(excludedPackageNames.value + packageName) - App.get().addUserDisallowedPackageName(packageName) + selectedPackageNames.set(selectedPackageNames.value + packageName) + App.get().addUserSelectedPackage(packageName) } - fun unexclude(packageName: String) { - excludedPackageNames.set(excludedPackageNames.value - packageName) - App.get().removeUserDisallowedPackageName(packageName) + fun deselect(packageName: String) { + selectedPackageNames.set(selectedPackageNames.value - packageName) + App.get().removeUserSelectedPackage(packageName) } } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 5626e583b0..4c284bcbca 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -281,11 +281,18 @@ 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 + Filter what apps are allowed to access Tailscale Apps selected here will access the Internet directly, without using Tailscale. + Only apps selected here will be allowed to access Tailscale. 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. + Switch to select included + Switch to select excluded + Selected apps will be reset + By switching filters, all your previously selected applications will be reset. Please ensure that this is intended. + Switch 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. Specifies a list of apps that will always use Tailscale routes and DNS when Tailscale is running. All other apps won\'t use Tailscale if this value is non-empty. Included packages