From 46501adbd07a30b91806d3bf8dcd825ecd5b175e Mon Sep 17 00:00:00 2001 From: flymylee <28243034+flymylee@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:31:20 -0400 Subject: [PATCH] android: split-tunnel UX: add include/exclude mode and bulk selection --- .../src/main/java/com/tailscale/ipn/App.kt | 2 +- .../ipn/ui/view/SplitTunnelAppPickerView.kt | 95 ++++++++++--------- .../SplitTunnelAppPickerViewModel.kt | 22 ++++- android/src/main/res/values/strings.xml | 4 + 4 files changed, 78 insertions(+), 45 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/App.kt b/android/src/main/java/com/tailscale/ipn/App.kt index abf4ea9e47..7c5b2f95c3 100644 --- a/android/src/main/java/com/tailscale/ipn/App.kt +++ b/android/src/main/java/com/tailscale/ipn/App.kt @@ -709,7 +709,7 @@ open class UninitializedApp : Application() { } fun allowSelectedPackages(): Boolean { - return getUnencryptedPrefs().getBoolean(ALLOW_SELECTED_APPS_KEY, false) + return getUnencryptedPrefs().getBoolean(ALLOW_SELECTED_APPS_KEY, true) } fun getAppScopedViewModel(): AppViewModel { 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 5994532ed0..d9c58f003a 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,26 +4,25 @@ 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.fillMaxSize +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.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -43,6 +42,7 @@ import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.viewModel.SplitTunnelAppPickerViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SplitTunnelAppPickerView( backToSettings: BackNavigation, @@ -54,7 +54,6 @@ fun SplitTunnelAppPickerView( 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() if (showSwitchDialog) { @@ -70,18 +69,7 @@ fun SplitTunnelAppPickerView( 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") - } - } - }, - ) + Header(titleRes = R.string.split_tunneling, onBack = backToSettings) }, ) { innerPadding -> LazyColumn(modifier = Modifier.padding(innerPadding)) { @@ -111,6 +99,34 @@ fun SplitTunnelAppPickerView( .selected_apps_will_access_the_internet_directly_without_using_tailscale)) }) } + item("modeSelector") { + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + SegmentedButton( + selected = !allowSelected, + onClick = { + if (allowSelected) { + model.showSwitchDialog.set(true) + } + }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { + Text(stringResource(R.string.split_tunnel_mode_exclude)) + } + SegmentedButton( + selected = allowSelected, + onClick = { + if (!allowSelected) { + model.showSwitchDialog.set(true) + } + }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { + Text(stringResource(R.string.split_tunnel_mode_include)) + } + } + } item("resolversHeader") { Lists.SectionDivider( stringResource( @@ -118,6 +134,22 @@ fun SplitTunnelAppPickerView( selectedPackageNames.count(), )) } + item("bulkActions") { + Row(modifier = Modifier.padding(horizontal = 8.dp)) { + TextButton( + onClick = { model.selectAll() }, + enabled = installedApps.isNotEmpty(), + ) { + Text(stringResource(R.string.select_all_apps)) + } + TextButton( + onClick = { model.deselectAll() }, + enabled = installedApps.isNotEmpty(), + ) { + Text(stringResource(R.string.deselect_all_apps)) + } + } + } if (installedApps.isEmpty()) { item("spinner") { Box( @@ -177,30 +209,7 @@ 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(allowSelected: Boolean, onConfirm: (() -> Unit), onDismiss: (() -> Unit)) { +fun SwitchAlertDialog(allowSelected: Boolean, onConfirm: () -> Unit, onDismiss: () -> Unit) { val switchString = stringResource( if (allowSelected) R.string.switch_to_select_to_exclude 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 8b0522a3b4..ba25be0e93 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 @@ -39,7 +39,6 @@ class SplitTunnelAppPickerViewModel : ViewModel() { val selectedPackageNames: StateFlow> = MutableStateFlow(listOf()) val allowSelected: StateFlow = MutableStateFlow(App.get().allowSelectedPackages()) - val showHeaderMenu: StateFlow = MutableStateFlow(false) val showSwitchDialog: StateFlow = MutableStateFlow(false) val mdmExcludedPackages: StateFlow> = MDMSettings.excludedPackages.flow @@ -80,6 +79,27 @@ class SplitTunnelAppPickerViewModel : ViewModel() { debounceSave() } + private fun toggleablePackageNames(): List = + installedApps.value + .map { it.packageName } + .filter { !App.get().builtInDisallowedPackageNames.contains(it) } + + fun selectAll() { + val newSet = selectedPackageNames.value.toMutableSet() + toggleablePackageNames().forEach { newSet.add(it) } + selectedPackageNames.set( + installedApps.value.map { it.packageName }.filter { newSet.contains(it) }) + debounceSave() + } + + fun deselectAll() { + val builtIn = App.get().builtInDisallowedPackageNames.toSet() + val kept = selectedPackageNames.value.filter { builtIn.contains(it) }.toSet() + selectedPackageNames.set( + installedApps.value.map { it.packageName }.filter { kept.contains(it) }) + debounceSave() + } + private fun debounceSave() { saveJob?.cancel() saveJob = diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index f8f852a0bf..74b5a518e5 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -315,6 +315,10 @@ Only apps you select will be allowed to access Tailscale. Excluded apps (%1$s) Included apps (%1$s) + Exclude + Include only + Select all + Deselect all 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 including