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)) } }