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)