From da4b3e710070e23c8038787766a8445e9b827362 Mon Sep 17 00:00:00 2001 From: fengzzyun Date: Sun, 3 May 2026 18:36:06 +0800 Subject: [PATCH] android: add search bar to split-tunnel app picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a search/filter bar at the top of the split-tunnel app selection list. The search matches against both app display names and package names (case-insensitive). Typing a query filters the list in real-time; a clear button (✕) appears when the field is non-empty. Existing strings (search, search_ellipsis, clear_search) are reused. Updates tailscale/tailscale#13816 Signed-off-by: fengzzyun --- .../ipn/ui/view/SplitTunnelAppPickerView.kt | 30 ++++++++++++++++++- .../SplitTunnelAppPickerViewModel.kt | 23 ++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) 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..c27d5888c7 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 @@ -8,13 +8,16 @@ 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.Close import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator @@ -23,6 +26,7 @@ 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.Text import androidx.compose.material3.TextButton @@ -49,6 +53,8 @@ fun SplitTunnelAppPickerView( model: SplitTunnelAppPickerViewModel = viewModel(), ) { val installedApps by model.installedApps.collectAsState() + val filteredApps by model.filteredApps.collectAsState() + val searchQuery by model.searchQuery.collectAsState() val selectedPackageNames by model.selectedPackageNames.collectAsState() val allowSelected by model.allowSelected.collectAsState() val builtInDisallowedPackageNames: List = App.get().builtInDisallowedPackageNames @@ -118,6 +124,28 @@ fun SplitTunnelAppPickerView( selectedPackageNames.count(), )) } + item("searchBar") { + OutlinedTextField( + value = searchQuery, + onValueChange = { model.updateSearchQuery(it) }, + placeholder = { Text(stringResource(R.string.search_ellipsis)) }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { model.updateSearchQuery("") }) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.clear_search)) + } + } + }, + singleLine = true, + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + ) + } if (installedApps.isEmpty()) { item("spinner") { Box( @@ -132,7 +160,7 @@ fun SplitTunnelAppPickerView( } } } else { - items(installedApps, key = { it.packageName }) { app -> + items(filteredApps, key = { it.packageName }) { app -> ListItem( headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) }, leadingContent = { 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..479f8ec1cf 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 @@ -17,6 +17,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn @@ -38,6 +39,28 @@ class SplitTunnelAppPickerViewModel : ViewModel() { ) val selectedPackageNames: StateFlow> = MutableStateFlow(listOf()) + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery + + val filteredApps: StateFlow> = + combine(installedApps, _searchQuery) { apps, query -> + if (query.isBlank()) apps + else + apps.filter { app -> + app.name.contains(query, ignoreCase = true) || + app.packageName.contains(query, ignoreCase = true) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = listOf(), + ) + + fun updateSearchQuery(query: String) { + _searchQuery.value = query + } + val allowSelected: StateFlow = MutableStateFlow(App.get().allowSelectedPackages()) val showHeaderMenu: StateFlow = MutableStateFlow(false) val showSwitchDialog: StateFlow = MutableStateFlow(false)