android: use native search (#547)

-Add dynamic suggestions
-Use search bar with expanded view showing suggestions
-dpad: only open keyboard when clicked on and not on scroll

Updates tailscale/corp#18973
Fixes tailscale/corp#19231

Signed-off-by: kari-ts <kari@tailscale.com>
pull/551/head
kari-ts 2 weeks ago committed by GitHub
parent 0bd4ef932b
commit c7b1362451
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -134,6 +134,7 @@ dependencies {
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version" implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"
implementation "androidx.core:core-splashscreen:1.1.0-rc01" implementation "androidx.core:core-splashscreen:1.1.0-rc01"
implementation "androidx.compose.animation:animation:1.7.4"
// Navigation dependencies. // Navigation dependencies.
def nav_version = "2.8.2" def nav_version = "2.8.2"

@ -22,13 +22,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
@ -40,25 +41,23 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
@ -86,7 +85,6 @@ import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem import com.tailscale.ipn.ui.theme.primaryListItem
import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.surfaceContainerListItem
@ -526,62 +524,29 @@ fun PeerList(
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
var isFocussed by remember { mutableStateOf(false) } var isFocussed by remember { mutableStateOf(false) }
var isListFocussed by remember { mutableStateOf(false) } var isListFocussed by remember { mutableStateOf(false) }
val expandedPeer = viewModel.expandedMenuPeer.collectAsState() val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val enableSearch = !isAndroidTV() val enableSearch = !isAndroidTV()
Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch) { if (enableSearch) {
Box(modifier = Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { SearchWithDynamicSuggestions(viewModel, onSearch)
OutlinedTextField(
modifier = Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
Modifier.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp)
.onFocusChanged { isFocussed = it.isFocused },
singleLine = true,
shape = MaterialTheme.shapes.extraLarge,
colors = MaterialTheme.colorScheme.searchBarColors,
leadingIcon = {
Icon(imageVector = Icons.Outlined.Search, contentDescription = "search")
},
trailingIcon = {
if (isFocussed) {
IconButton(
onClick = {
focusManager.clearFocus()
onSearch("")
}) {
Icon(
imageVector =
if (searchTermStr.isEmpty()) Icons.Outlined.Close
else Icons.Outlined.Clear,
contentDescription = "clear search",
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
},
placeholder = {
Text(
text = stringResource(id = R.string.search),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1)
},
value = searchTermStr,
onValueChange = { onSearch(it) })
}
} }
// Peers display
LazyColumn( LazyColumn(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxWidth()
.weight(1f) // LazyColumn gets the remaining vertical space
.onFocusChanged { isListFocussed = it.isFocused } .onFocusChanged { isListFocussed = it.isFocused }
.background(color = MaterialTheme.colorScheme.surface)) { .background(color = MaterialTheme.colorScheme.surface)) {
// Handle case when no results are found
if (showNoResults) { if (showNoResults) {
item { item {
Spacer( Spacer(
@ -589,7 +554,6 @@ fun PeerList(
.fillMaxSize() .fillMaxSize()
.focusable(false) .focusable(false)
.background(color = MaterialTheme.colorScheme.surface)) .background(color = MaterialTheme.colorScheme.surface))
Lists.LargeTitle( Lists.LargeTitle(
stringResource(id = R.string.no_results), stringResource(id = R.string.no_results),
bottomPadding = 8.dp, bottomPadding = 8.dp,
@ -598,6 +562,7 @@ fun PeerList(
} }
} }
// Iterate over peer sets to display them
var first = true var first = true
peerList.forEach { peerSet -> peerList.forEach { peerSet ->
if (!first) { if (!first) {
@ -605,7 +570,6 @@ fun PeerList(
} }
first = false first = false
// Sticky headers are a bit broken on Android TV - they hide their content
if (isAndroidTV()) { if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) } item { NodesSectionHeader(peerSet = peerSet) }
} else { } else {
@ -644,10 +608,8 @@ fun PeerList(
viewModel.copyIpAddress(peer, localClipboardManager) viewModel.copyIpAddress(peer, localClipboardManager)
viewModel.hidePeerDropdownMenu() viewModel.hidePeerDropdownMenu()
}) })
netmap.value?.let { netMap -> netmap.value?.let { netMap ->
if (!peer.isSelfNode(netMap)) { if (!peer.isSelfNode(netMap)) {
// Don't show the ping item for the self-node
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { leadingIcon = {
Icon( Icon(
@ -674,6 +636,7 @@ fun PeerList(
} }
} }
} }
}
} }
@Composable @Composable
@ -729,6 +692,103 @@ fun PromptPermissionsIfNecessary() {
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchWithDynamicSuggestions(viewModel: MainViewModel, onSearch: (String) -> Unit) {
val searchTerm by viewModel.searchTerm.collectAsState()
val filteredPeers by viewModel.peers.collectAsState()
var expanded by rememberSaveable { mutableStateOf(false) }
val netmap by viewModel.netmap.collectAsState()
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
Column(
modifier =
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
focusRequester.requestFocus()
keyboardController?.show()
}) {
SearchBar(
modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally),
inputField = {
SearchBarDefaults.InputField(
query = searchTerm,
onQueryChange = { query ->
viewModel.updateSearchTerm(query)
onSearch(query)
expanded = query.isNotEmpty()
},
onSearch = { query ->
viewModel.updateSearchTerm(query)
onSearch(query)
expanded = false
},
expanded = expanded,
onExpandedChange = { expanded = it },
placeholder = { Text("Search") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
trailingIcon = {
if (expanded) {
IconButton(
onClick = {
viewModel.updateSearchTerm("")
onSearch("")
expanded = false
focusManager.clearFocus()
keyboardController?.hide()
}) {
Icon(Icons.Default.Clear, contentDescription = "Clear search")
}
}
})
},
expanded = expanded,
onExpandedChange = { expanded = it },
content = {
// Search results or suggestions
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
filteredPeers.forEach { peerSet ->
val userName = peerSet.user?.DisplayName ?: "Unknown User"
peerSet.peers.forEach { peer ->
val deviceName = peer.displayName ?: "Unknown Device"
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
ListItem(
headlineContent = { Text(userName) },
supportingContent = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
val onlineColor = peer.connectedColor(netmap)
Box(
modifier =
Modifier.size(10.dp)
.background(onlineColor, shape = RoundedCornerShape(50)))
Spacer(modifier = Modifier.size(8.dp))
Text(deviceName)
}
Text(ipAddress)
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier =
Modifier.clickable {
viewModel.updateSearchTerm(userName)
onSearch(userName)
expanded = false
focusManager.clearFocus()
keyboardController?.hide()
}
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp))
}
}
}
})
}
}
@Preview @Preview
@Composable @Composable
fun MainViewPreview() { fun MainViewPreview() {

@ -55,13 +55,15 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null private var vpnPermissionLauncher: ActivityResultLauncher<Intent>? = null
// The list of peers // The list of peers
val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>()) private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers
// The current state of the IPN for determining view visibility // The current state of the IPN for determining view visibility
val ipnState = Notifier.state val ipnState = Notifier.state
// The active search term for filtering peers // The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("") private val _searchTerm = MutableStateFlow("")
val searchTerm: StateFlow<String> = _searchTerm
// True if we should render the key expiry bannder // True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false) val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
@ -78,6 +80,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// Icon displayed in the button to present the health view // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
fun updateSearchTerm(term: String) {
_searchTerm.value = term
}
fun hidePeerDropdownMenu() { fun hidePeerDropdownMenu() {
expandedMenuPeer.set(null) expandedMenuPeer.set(null)
} }
@ -123,8 +129,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
searchTerm.debounce(250L).collect { term -> _searchTerm.debounce(250L).collect { term ->
peers.set(peerCategorizer.groupedAndFilteredPeers(term)) val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term)
_peers.value = filteredPeers
} }
} }
@ -132,7 +139,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
Notifier.netmap.collect { it -> Notifier.netmap.collect { it ->
it?.let { netmap -> it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap) peerCategorizer.regenerateGroupedPeers(netmap)
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
// Immediately update _peers with the full peer list
_peers.value = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
if (netmap.SelfNode.keyDoesNotExpire) { if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false) showExpiry.set(false)

Loading…
Cancel
Save