android: move node search to background and fix avatar padding (#574)

android: use background search and fix avatar padding

fixes tailscale/corp#24847
fixes tailsacle/corp#24848

Search jobs are moved to the default dispatcher so they
do not block the UI thread.

The avatar boxing is now used only conditionally on AndroidTV.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/576/head
Jonathan Nobels 1 year ago committed by GitHub
parent fda3820582
commit f35b3f9274
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -163,7 +163,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels() initViewModels()
applicationScope.launch { applicationScope.launch {
Notifier.state.collect { state -> Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled -> combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
Pair(state, forceEnabled) Pair(state, forceEnabled)
} }

@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.ui.Modifier
/// Applies different modifiers to the receiver based on a condition.
inline fun Modifier.conditional(
condition: Boolean,
ifTrue: Modifier.() -> Modifier,
ifFalse: Modifier.() -> Modifier = { this },
): Modifier =
if (condition) {
then(ifTrue(Modifier))
} else {
then(ifFalse(Modifier))
}

@ -31,6 +31,8 @@ import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.conditional
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@Composable @Composable
@ -46,17 +48,18 @@ fun Avatar(
// Outer Box for the larger focusable and clickable area // Outer Box for the larger focusable and clickable area
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier =
.padding(4.dp) Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) })
.size((size * 1.5f).dp) // Focusable area is larger than the avatar .conditional(
AndroidTVUtil.isAndroidTV(),
{
size((size * 1.5f).dp) // Focusable area is larger than the avatar
})
.clip(CircleShape) // Ensure both the focus and click area are circular .clip(CircleShape) // Ensure both the focus and click area are circular
.background( .background(
if (isFocused.value) MaterialTheme.colorScheme.surface if (isFocused.value) MaterialTheme.colorScheme.surface else Color.Transparent,
else Color.Transparent,
) )
.onFocusChanged { focusState -> .onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
isFocused.value = focusState.isFocused
}
.focusable() // Make this outer Box focusable (after onFocusChanged) .focusable() // Make this outer Box focusable (after onFocusChanged)
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
@ -64,22 +67,17 @@ fun Avatar(
onClick = { onClick = {
action?.invoke() action?.invoke()
focusManager.clearFocus() // Clear focus after clicking the avatar focusManager.clearFocus() // Clear focus after clicking the avatar
} })) {
)
) {
// Inner Box to hold the avatar content (Icon or AsyncImage) // Inner Box to hold the avatar content (Icon or AsyncImage)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier.size(size.dp).clip(CircleShape)) {
.size(size.dp)
.clip(CircleShape)
) {
// Always display the default icon as a background layer // Always display the default icon as a background layer
Icon( Icon(
imageVector = Icons.Default.Person, imageVector = Icons.Default.Person,
contentDescription = stringResource(R.string.settings_title), contentDescription = stringResource(R.string.settings_title),
modifier = modifier =
Modifier.size((size * 0.8f).dp) Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) })
.clip(CircleShape) // Icon size slightly smaller than the Box .clip(CircleShape) // Icon size slightly smaller than the Box
) )

@ -23,7 +23,9 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -77,6 +79,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive
var searchJob: Job? = null
// Icon displayed in the button to present the health view // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
@ -130,18 +134,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
_searchTerm.debounce(250L).collect { term -> _searchTerm.debounce(250L).collect { term ->
// run the search as a background task
searchJob?.cancel()
searchJob =
launch(Dispatchers.Default) {
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term) val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term)
_peers.value = filteredPeers _peers.value = filteredPeers
} }
} }
}
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.collect { it -> Notifier.netmap.collect { it ->
it?.let { netmap -> it?.let { netmap ->
searchJob?.cancel()
launch(Dispatchers.Default) {
peerCategorizer.regenerateGroupedPeers(netmap) peerCategorizer.regenerateGroupedPeers(netmap)
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
// Immediately update _peers with the full peer list _peers.value = filteredPeers
_peers.value = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) }
if (netmap.SelfNode.keyDoesNotExpire) { if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false) showExpiry.set(false)

Loading…
Cancel
Save