android: optimize peer search

Updates tailscale/corp#18202

Switch to LazyColumn so we're not redrawing the entire list.

Modify the search logic so we're searching progressively and doing all of the sorting and categorization up front on netmap changes.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/207/head
Jonathan Nobels 2 months ago committed by Percy Wegmann
parent 7c64091aab
commit 2c694b7159

@ -8,51 +8,98 @@ import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.service.IpnModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
class PeerCategorizer(val model: IpnModel) {
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
val netmap: Netmap.NetworkMap = model.netmap.value ?: return emptyList()
typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>>
class PeerCategorizer(val model: IpnModel, val scope: CoroutineScope) {
var peerSets: List<PeerSet> = emptyList()
var lastSearchResult: List<PeerSet> = emptyList()
var searchTerm: String = ""
// Keep the peer sets current while the model is active
init {
scope.launch {
model.netmap.collect { netmap ->
netmap?.let {
peerSets = regenerateGroupedPeers(netmap)
lastSearchResult = peerSets
} ?: run {
peerSets = emptyList()
lastSearchResult = emptyList()
}
}
}
}
private fun regenerateGroupedPeers(netmap: Netmap.NetworkMap): List<PeerSet> {
val peers: List<Tailcfg.Node> = netmap.Peers ?: return emptyList()
val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
val grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
for (peer in (peers + selfNode)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User
if (searchTerm.isNotEmpty() && !peer.ComputedName.contains(searchTerm, ignoreCase = true)) {
continue
}
if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf()
}
grouped[userId]?.add(peer)
}
var selfPeers = (grouped[selfNode.User] ?: emptyList()).sortedBy { it.ComputedName }
grouped.remove(selfNode.User)
val currentNode = selfPeers.firstOrNull { it.ID == selfNode.ID }
currentNode?.let {
selfPeers = selfPeers.filter { it.ID != currentNode.ID }
selfPeers = listOf(currentNode) + selfPeers
}
val me = netmap.currentUserProfile()
val sorted = grouped.map { (userId, peers) ->
val peerSets = grouped.map { (userId, peers) ->
val profile = netmap.userProfile(userId)
PeerSet(profile, peers)
PeerSet(profile, peers.sortedBy { it.ComputedName })
}.sortedBy {
it.user?.DisplayName ?: "Unknown User"
if (it.user?.ID == me?.ID) {
""
} else {
it.user?.DisplayName ?: "Unknown User"
}
}
val me = netmap.currentUserProfile()
return if (selfPeers.isEmpty()) {
sorted
} else {
listOf(PeerSet(me, selfPeers)) + sorted
return peerSets
}
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
if (searchTerm.isEmpty()) {
return peerSets
}
if (searchTerm == this.searchTerm) {
return lastSearchResult
}
// We can optimize out typing... If the search term starts with the last search term, we can just search the last result
val setsToSearch = if (searchTerm.startsWith(this.searchTerm)) lastSearchResult else peerSets
this.searchTerm = searchTerm
val matchingSets = setsToSearch.map { peerSet ->
val user = peerSet.user
val peers = peerSet.peers
val userMatches = user?.DisplayName?.contains(searchTerm, ignoreCase = true) ?: false
if (userMatches) {
return@map peerSet
}
val matchingPeers = peers.filter { it.ComputedName.contains(searchTerm, ignoreCase = true) }
if (matchingPeers.isNotEmpty()) {
PeerSet(user, matchingPeers)
} else {
null
}
}.filterNotNull()
return matchingSets
}
}

@ -15,9 +15,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.ArrowDropDown
@ -218,47 +217,52 @@ fun PeerList(searchTerm: StateFlow<String>, peers: StateFlow<List<PeerSet>>, onN
colors = SearchBarDefaults.colors(),
modifier = Modifier.fillMaxWidth()) {
Column(
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
peerList.value.forEach { peerSet ->
ListItem(headlineContent = {
Text(text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
})
item {
ListItem(headlineContent = {
Text(text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
})
}
peerSet.peers.forEach { peer ->
ListItem(
modifier = Modifier.clickable {
onNavigateToPeerDetails(peer)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
val color: Color = if (peer.Online ?: false) {
Color.Green
} else {
Color.Gray
item {
ListItem(
modifier = Modifier.clickable {
onNavigateToPeerDetails(peer)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected.
val color: Color = if ((peer.Online == true)) {
Color.Green
} else {
Color.Gray
}
Box(modifier = Modifier
.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
}
Box(modifier = Modifier
.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first()
?: "",
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
)
)
}
}
}
}

@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.set
@ -38,6 +39,12 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
val searchTerm: StateFlow<String> = MutableStateFlow("")
// The current peer ID
val selfPeerId: StableNodeID
get() = model.netmap.value?.SelfNode?.StableID ?: ""
val peerCategorizer = PeerCategorizer(model, viewModelScope)
init {
viewModelScope.launch {
model.state.collect { state ->
@ -48,7 +55,7 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
viewModelScope.launch {
model.netmap.collect { netmap ->
peers.set(PeerCategorizer(model).groupedAndFilteredPeers(searchTerm.value))
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm.value))
}
}
}
@ -56,7 +63,7 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm)
viewModelScope.launch {
peers.set(PeerCategorizer(model).groupedAndFilteredPeers(searchTerm))
peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm))
}
}

Loading…
Cancel
Save