Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) {
Expand Down
229 changes: 103 additions & 126 deletions android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,18 +19,16 @@ 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
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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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 =
Expand All @@ -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
Expand All @@ -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}")
})
}
}
}
}
}
}
}
}
})
}
}
}
Loading