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 3 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.Tailcfg
import com.tailscale.ipn.ui.model.UserID import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.service.IpnModel 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>) data class PeerSet(val user: Tailcfg.UserProfile?, val peers: List<Tailcfg.Node>)
class PeerCategorizer(val model: IpnModel) { typealias GroupedPeers = MutableMap<UserID, MutableList<Tailcfg.Node>>
fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
val netmap: Netmap.NetworkMap = model.netmap.value ?: return emptyList() 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 peers: List<Tailcfg.Node> = netmap.Peers ?: return emptyList()
val selfNode = netmap.SelfNode val selfNode = netmap.SelfNode
var grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
val grouped = mutableMapOf<UserID, MutableList<Tailcfg.Node>>()
for (peer in (peers + selfNode)) { for (peer in (peers + selfNode)) {
// (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user // (jonathan) TODO: MDM -> There are a number of MDM settings to hide devices from the user
// (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices // (jonathan) TODO: MDM -> currentUser, otherUsers, taggedDevices
val userId = peer.User val userId = peer.User
if (searchTerm.isNotEmpty() && !peer.ComputedName.contains(searchTerm, ignoreCase = true)) {
continue
}
if (!grouped.containsKey(userId)) { if (!grouped.containsKey(userId)) {
grouped[userId] = mutableListOf() grouped[userId] = mutableListOf()
} }
grouped[userId]?.add(peer) 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 } val me = netmap.currentUserProfile()
currentNode?.let {
selfPeers = selfPeers.filter { it.ID != currentNode.ID }
selfPeers = listOf(currentNode) + selfPeers
}
val sorted = grouped.map { (userId, peers) -> val peerSets = grouped.map { (userId, peers) ->
val profile = netmap.userProfile(userId) val profile = netmap.userProfile(userId)
PeerSet(profile, peers) PeerSet(profile, peers.sortedBy { it.ComputedName })
}.sortedBy { }.sortedBy {
it.user?.DisplayName ?: "Unknown User" if (it.user?.ID == me?.ID) {
""
} else {
it.user?.DisplayName ?: "Unknown User"
}
} }
val me = netmap.currentUserProfile() return peerSets
return if (selfPeers.isEmpty()) { }
sorted
} else { fun groupedAndFilteredPeers(searchTerm: String = ""): List<PeerSet> {
listOf(PeerSet(me, selfPeers)) + sorted 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
@ -218,47 +217,52 @@ fun PeerList(searchTerm: StateFlow<String>, peers: StateFlow<List<PeerSet>>, onN
colors = SearchBarDefaults.colors(), colors = SearchBarDefaults.colors(),
modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth()) {
Column( LazyColumn(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.background(MaterialTheme.colorScheme.secondaryContainer), .background(MaterialTheme.colorScheme.secondaryContainer),
) { ) {
peerList.value.forEach { peerSet -> peerList.value.forEach { peerSet ->
ListItem(headlineContent = { item {
Text(text = peerSet.user?.DisplayName ListItem(headlineContent = {
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge) Text(text = peerSet.user?.DisplayName
}) ?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
})
}
peerSet.peers.forEach { peer -> peerSet.peers.forEach { peer ->
ListItem( item {
modifier = Modifier.clickable { ListItem(
onNavigateToPeerDetails(peer) modifier = Modifier.clickable {
}, onNavigateToPeerDetails(peer)
headlineContent = { },
Row(verticalAlignment = Alignment.CenterVertically) { headlineContent = {
val color: Color = if (peer.Online ?: false) { Row(verticalAlignment = Alignment.CenterVertically) {
Color.Green // By definition, SelfPeer is online since we will not show the peer list unless you're connected.
} else { val color: Color = if ((peer.Online == true)) {
Color.Gray 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) supportingContent = {
.background(color = color, shape = RoundedCornerShape(percent = 50))) {} Text(
Spacer(modifier = Modifier.size(8.dp)) text = peer.Addresses?.first()?.split("/")?.first()
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) ?: "",
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 androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State 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.IpnActions
import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.set 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("") val searchTerm: StateFlow<String> = MutableStateFlow("")
// The current peer ID
val selfPeerId: StableNodeID
get() = model.netmap.value?.SelfNode?.StableID ?: ""
val peerCategorizer = PeerCategorizer(model, viewModelScope)
init { init {
viewModelScope.launch { viewModelScope.launch {
model.state.collect { state -> model.state.collect { state ->
@ -48,7 +55,7 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
viewModelScope.launch { viewModelScope.launch {
model.netmap.collect { netmap -> 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) { fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
viewModelScope.launch { viewModelScope.launch {
peers.set(PeerCategorizer(model).groupedAndFilteredPeers(searchTerm)) peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm))
} }
} }

Loading…
Cancel
Save