From 2c694b7159bf283c67a1ec5526ed10a323910b2d Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Thu, 14 Mar 2024 11:15:51 -0400 Subject: [PATCH] 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 --- .../com/tailscale/ipn/ui/util/PeerHelper.kt | 93 ++++++++++++++----- .../com/tailscale/ipn/ui/view/MainView.kt | 72 +++++++------- .../ipn/ui/viewModel/MainViewModel.kt | 11 ++- 3 files changed, 117 insertions(+), 59 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt index b3be1de..0d8d5d2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/PeerHelper.kt @@ -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) -class PeerCategorizer(val model: IpnModel) { - fun groupedAndFilteredPeers(searchTerm: String = ""): List { - val netmap: Netmap.NetworkMap = model.netmap.value ?: return emptyList() +typealias GroupedPeers = MutableMap> + +class PeerCategorizer(val model: IpnModel, val scope: CoroutineScope) { + var peerSets: List = emptyList() + var lastSearchResult: List = 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 { val peers: List = netmap.Peers ?: return emptyList() val selfNode = netmap.SelfNode + var grouped = mutableMapOf>() - val grouped = mutableMapOf>() 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 { + 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 } + } \ No newline at end of file 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 98ea881..d56d0c6 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 @@ -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, peers: StateFlow>, 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) - } - ) + ) + } } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index eebee93..a05ed0c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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 = 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)) } }