Skip to content

Commit f35b3f9

Browse files
authored
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 <[email protected]>
1 parent fda3820 commit f35b3f9

File tree

4 files changed

+78
-51
lines changed

4 files changed

+78
-51
lines changed

android/src/main/java/com/tailscale/ipn/App.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
163163
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
164164
initViewModels()
165165
applicationScope.launch {
166-
Notifier.state.collect { state ->
166+
Notifier.state.collect { _ ->
167167
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
168168
Pair(state, forceEnabled)
169169
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package com.tailscale.ipn.ui.util
5+
6+
import androidx.compose.ui.Modifier
7+
8+
/// Applies different modifiers to the receiver based on a condition.
9+
inline fun Modifier.conditional(
10+
condition: Boolean,
11+
ifTrue: Modifier.() -> Modifier,
12+
ifFalse: Modifier.() -> Modifier = { this },
13+
): Modifier =
14+
if (condition) {
15+
then(ifTrue(Modifier))
16+
} else {
17+
then(ifFalse(Modifier))
18+
}

android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt

Lines changed: 42 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import coil.annotation.ExperimentalCoilApi
3131
import coil.compose.AsyncImage
3232
import com.tailscale.ipn.R
3333
import com.tailscale.ipn.ui.model.IpnLocal
34+
import com.tailscale.ipn.ui.util.AndroidTVUtil
35+
import com.tailscale.ipn.ui.util.conditional
3436

3537
@OptIn(ExperimentalCoilApi::class)
3638
@Composable
@@ -43,53 +45,49 @@ fun Avatar(
4345
var isFocused = remember { mutableStateOf(false) }
4446
val focusManager = LocalFocusManager.current
4547

46-
// Outer Box for the larger focusable and clickable area
47-
Box(
48-
contentAlignment = Alignment.Center,
49-
modifier = Modifier
50-
.padding(4.dp)
51-
.size((size * 1.5f).dp) // Focusable area is larger than the avatar
52-
.clip(CircleShape) // Ensure both the focus and click area are circular
53-
.background(
54-
if (isFocused.value) MaterialTheme.colorScheme.surface
55-
else Color.Transparent,
56-
)
57-
.onFocusChanged { focusState ->
58-
isFocused.value = focusState.isFocused
59-
}
60-
.focusable() // Make this outer Box focusable (after onFocusChanged)
61-
.clickable(
62-
interactionSource = remember { MutableInteractionSource() },
63-
indication = ripple(bounded = true), // Apply ripple effect inside circular bounds
64-
onClick = {
65-
action?.invoke()
48+
// Outer Box for the larger focusable and clickable area
49+
Box(
50+
contentAlignment = Alignment.Center,
51+
modifier =
52+
Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) })
53+
.conditional(
54+
AndroidTVUtil.isAndroidTV(),
55+
{
56+
size((size * 1.5f).dp) // Focusable area is larger than the avatar
57+
})
58+
.clip(CircleShape) // Ensure both the focus and click area are circular
59+
.background(
60+
if (isFocused.value) MaterialTheme.colorScheme.surface else Color.Transparent,
61+
)
62+
.onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
63+
.focusable() // Make this outer Box focusable (after onFocusChanged)
64+
.clickable(
65+
interactionSource = remember { MutableInteractionSource() },
66+
indication = ripple(bounded = true), // Apply ripple effect inside circular bounds
67+
onClick = {
68+
action?.invoke()
6669
focusManager.clearFocus() // Clear focus after clicking the avatar
67-
}
68-
)
69-
) {
70+
})) {
7071
// Inner Box to hold the avatar content (Icon or AsyncImage)
7172
Box(
7273
contentAlignment = Alignment.Center,
73-
modifier = Modifier
74-
.size(size.dp)
75-
.clip(CircleShape)
76-
) {
77-
// Always display the default icon as a background layer
78-
Icon(
79-
imageVector = Icons.Default.Person,
80-
contentDescription = stringResource(R.string.settings_title),
81-
modifier =
82-
Modifier.size((size * 0.8f).dp)
83-
.clip(CircleShape) // Icon size slightly smaller than the Box
84-
)
74+
modifier = Modifier.size(size.dp).clip(CircleShape)) {
75+
// Always display the default icon as a background layer
76+
Icon(
77+
imageVector = Icons.Default.Person,
78+
contentDescription = stringResource(R.string.settings_title),
79+
modifier =
80+
Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) })
81+
.clip(CircleShape) // Icon size slightly smaller than the Box
82+
)
8583

86-
// Overlay the profile picture if available
87-
profile?.UserProfile?.ProfilePicURL?.let { url ->
88-
AsyncImage(
89-
model = url,
90-
modifier = Modifier.size(size.dp).clip(CircleShape),
91-
contentDescription = null)
92-
}
93-
}
94-
}
84+
// Overlay the profile picture if available
85+
profile?.UserProfile?.ProfilePicURL?.let { url ->
86+
AsyncImage(
87+
model = url,
88+
modifier = Modifier.size(size.dp).clip(CircleShape),
89+
contentDescription = null)
90+
}
91+
}
92+
}
9593
}

android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
2323
import com.tailscale.ipn.ui.util.PeerSet
2424
import com.tailscale.ipn.ui.util.TimeUtil
2525
import com.tailscale.ipn.ui.util.set
26+
import kotlinx.coroutines.Dispatchers
2627
import kotlinx.coroutines.FlowPreview
28+
import kotlinx.coroutines.Job
2729
import kotlinx.coroutines.flow.MutableStateFlow
2830
import kotlinx.coroutines.flow.StateFlow
2931
import kotlinx.coroutines.flow.combine
@@ -77,6 +79,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
7779

7880
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive
7981

82+
var searchJob: Job? = null
83+
8084
// Icon displayed in the button to present the health view
8185
val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
8286

@@ -130,18 +134,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
130134

131135
viewModelScope.launch {
132136
_searchTerm.debounce(250L).collect { term ->
133-
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term)
134-
_peers.value = filteredPeers
137+
// run the search as a background task
138+
searchJob?.cancel()
139+
searchJob =
140+
launch(Dispatchers.Default) {
141+
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term)
142+
_peers.value = filteredPeers
143+
}
135144
}
136145
}
137146

138147
viewModelScope.launch {
139148
Notifier.netmap.collect { it ->
140149
it?.let { netmap ->
141-
peerCategorizer.regenerateGroupedPeers(netmap)
142-
143-
// Immediately update _peers with the full peer list
144-
_peers.value = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
150+
searchJob?.cancel()
151+
launch(Dispatchers.Default) {
152+
peerCategorizer.regenerateGroupedPeers(netmap)
153+
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
154+
_peers.value = filteredPeers
155+
}
145156

146157
if (netmap.SelfNode.keyDoesNotExpire) {
147158
showExpiry.set(false)

0 commit comments

Comments
 (0)