diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 23a22b2cd5..a3a53f8452 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -2,7 +2,6 @@ // SPDX-License-Identifier: BSD-3-Clause package com.tailscale.ipn.ui.view -import android.os.Build import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -561,10 +560,8 @@ fun PeerList( var isListFocussed by remember { mutableStateOf(false) } val expandedPeer = viewModel.expandedMenuPeer.collectAsState() val localClipboardManager = LocalClipboardManager.current - // Restrict search to devices running API 33+ (see https://github.com/tailscale/corp/issues/27375) - val enableSearch = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU Column(modifier = Modifier.fillMaxSize()) { - if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { + if (FeatureFlags.isEnabled("enable_new_search")) { Search(onSearchBarClick) } else { if (!isAndroidTV()) { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt index b5e7897e32..9e67c4d678 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -3,11 +3,7 @@ package com.tailscale.ipn.ui.view -import android.app.Activity -import android.os.Build -import android.window.OnBackInvokedCallback -import android.window.OnBackInvokedDispatcher -import androidx.annotation.RequiresApi +import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -23,7 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -31,10 +27,8 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SearchBar -import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -46,7 +40,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -61,7 +54,6 @@ import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.MainViewModel import kotlinx.coroutines.delay -@RequiresApi(Build.VERSION_CODES.TIRAMISU) @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchView( @@ -81,7 +73,6 @@ fun SearchView( val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current var expanded by rememberSaveable { mutableStateOf(true) } - val context = LocalContext.current as Activity val listState = rememberLazyListState() val noResultsBackground = @@ -91,19 +82,13 @@ fun SearchView( MaterialTheme.colorScheme.surfaceContainer // color for light mode } - val callback = OnBackInvokedCallback { + BackHandler { focusManager.clearFocus(force = true) keyboardController?.hide() onNavigateBack() viewModel.updateSearchTerm("") } - DisposableEffect(Unit) { - val dispatcher = context.onBackInvokedDispatcher - dispatcher.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, callback) - onDispose { dispatcher.unregisterOnBackInvokedCallback(callback) } - } - LaunchedEffect(searchTerm, filteredPeers) { if (searchTerm.isEmpty() && filteredPeers.isNotEmpty()) { delay(100) // Give Compose time to update list @@ -124,121 +109,113 @@ fun SearchView( Column(modifier = Modifier.fillMaxWidth().focusRequester(focusRequester)) { SearchBar( modifier = Modifier.fillMaxWidth(), - inputField = { - SearchBarDefaults.InputField( - query = searchTerm, - onQueryChange = { newQuery -> - // Create a new TextFieldValue with updated text and set cursor to the end. - searchFieldValue = - TextFieldValue(newQuery, selection = TextRange(newQuery.length)) - viewModel.updateSearchTerm(newQuery) - expanded = true - }, - onSearch = { newQuery -> - searchFieldValue = - TextFieldValue(newQuery, selection = TextRange(newQuery.length)) - viewModel.updateSearchTerm(newQuery) + query = searchTerm, + onQueryChange = { newQuery -> + // Create a new TextFieldValue with updated text and set cursor to the end. + searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length)) + viewModel.updateSearchTerm(newQuery) + expanded = true + }, + onSearch = { newQuery -> + searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length)) + viewModel.updateSearchTerm(newQuery) + focusManager.clearFocus() + keyboardController?.hide() + }, + placeholder = { Text(text = stringResource(R.string.search)) }, + leadingIcon = { + IconButton( + onClick = { focusManager.clearFocus() - keyboardController?.hide() - }, - expanded = expanded, - onExpandedChange = { expanded = it }, - placeholder = { Text(text = stringResource(R.string.search)) }, - leadingIcon = { - IconButton( - onClick = { - focusManager.clearFocus() - onNavigateBack() - viewModel.updateSearchTerm("") - }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onSurfaceVariant) - } - }, - trailingIcon = { - if (searchTerm.isNotEmpty()) { - IconButton( - onClick = { - searchFieldValue = TextFieldValue("", selection = TextRange(0)) - viewModel.updateSearchTerm("") - focusManager.clearFocus() - keyboardController?.hide() - }) { - Icon( - Icons.Default.Clear, - contentDescription = stringResource(R.string.clear_search)) - } - } - }, - ) + onNavigateBack() + viewModel.updateSearchTerm("") + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.search), + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } }, - expanded = expanded, - onExpandedChange = { expanded = it }, - ) { - LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { - if (filteredPeers.isEmpty()) { - // When there are no filtered peers, show a "No results" message. - item { - Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { - Lists.LargeTitle( - stringResource(id = R.string.no_results), - bottomPadding = 8.dp, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Light, - backgroundColor = noResultsBackground, - fontColor = MaterialTheme.colorScheme.onSurfaceVariant) - } + trailingIcon = { + if (searchTerm.isNotEmpty()) { + IconButton( + onClick = { + searchFieldValue = TextFieldValue("", selection = TextRange(0)) + viewModel.updateSearchTerm("") + focusManager.clearFocus() + keyboardController?.hide() + }) { + Icon( + Icons.Default.Clear, + contentDescription = stringResource(R.string.clear_search)) + } } - } else { - var firstGroup = true - filteredPeers.forEach { peerSet -> - if (!firstGroup) { - item { Lists.ItemDivider() } - } - firstGroup = false - - val userName = peerSet.user?.DisplayName ?: "Unknown User" - peerSet.peers.forEachIndexed { index, peer -> - if (index > 0) { - item(key = "divider_${peer.StableID}") { Lists.ItemDivider() } + }, + active = expanded, + onActiveChange = { expanded = it }, + content = { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + if (filteredPeers.isEmpty()) { + // When there are no filtered peers, show a "No results" message. + item { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Lists.LargeTitle( + stringResource(id = R.string.no_results), + bottomPadding = 8.dp, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Light, + backgroundColor = noResultsBackground, + fontColor = MaterialTheme.colorScheme.onSurfaceVariant) + } } - item(key = "peer_${peer.StableID}") { - ListItem( - colors = MaterialTheme.colorScheme.listItem, - headlineContent = { - Column { - Row(verticalAlignment = Alignment.CenterVertically) { - val onlineColor = peer.connectedColor(netmap) - Box( - modifier = - Modifier.size(10.dp) - .background(onlineColor, RoundedCornerShape(50))) - Spacer(modifier = Modifier.size(8.dp)) - Text(peer.displayName) - } - } - }, - supportingContent = { - Column { - Text(userName) - Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP") - } - }, - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 0.dp) - .clickable { - viewModel.disableSearchAutoFocus() - navController.navigate("peerDetails/${peer.StableID}") - }) + } else { + var firstGroup = true + filteredPeers.forEach { peerSet -> + if (!firstGroup) { + item { Lists.ItemDivider() } + } + firstGroup = false + + val userName = peerSet.user?.DisplayName ?: "Unknown User" + peerSet.peers.forEachIndexed { index, peer -> + if (index > 0) { + item(key = "divider_${peer.StableID}") { Lists.ItemDivider() } + } + item(key = "peer_${peer.StableID}") { + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + val onlineColor = peer.connectedColor(netmap) + Box( + modifier = + Modifier.size(10.dp) + .background(onlineColor, RoundedCornerShape(50))) + Spacer(modifier = Modifier.size(8.dp)) + Text(peer.displayName ?: "Unknown Device") + } + } + }, + supportingContent = { + Column { + Text(userName) + Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP") + } + }, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 0.dp) + .clickable { + viewModel.disableSearchAutoFocus() + navController.navigate("peerDetails/${peer.StableID}") + }) + } + } } } } - } - } - } + }) } } }