android: style exit node picker per Material UI and add disable button

Updates #ENG-2911

Signed-off-by: Percy Wegmann <percy@tailscale.com>
ox/exit_node_clickable
Percy Wegmann 2 months ago committed by Percy Wegmann
parent 3fea68ef2e
commit fb5635b8a5

@ -37,6 +37,7 @@ import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.Settings
@ -96,7 +97,9 @@ class MainActivity : ComponentActivity() {
onNavigateHome = {
navController.popBackStack(route = "main", inclusive = false)
},
onNavigateBack = { navController.popBackStack() },
onNavigateToExitNodePicker = { navController.popBackStack() },
onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
@ -104,15 +107,14 @@ class MainActivity : ComponentActivity() {
composable("settings") { Settings(settingsNav) }
navigation(startDestination = "list", route = "exitNodes") {
composable("list") { ExitNodePicker(exitNodePickerNav) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable(
"mullvad/{countryCode}",
arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
}
composable("runExitNode") {
RunExitNodeView(exitNodePickerNav)
}
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
}
composable(
"peerDetails/{nodeId}",

@ -0,0 +1,27 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.Role
/**
* Similar to Modifier.clickable, but if enabled == false, this adds a 75% alpha to make disabled
* items appear grayed out.
*/
@Composable
fun Modifier.clickableOrGrayedOut(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) =
if (enabled) {
clickable(onClickLabel = onClickLabel, role = role, onClick = onClick)
} else {
alpha(0.75f)
}

@ -0,0 +1,43 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
object Lists {
@Composable
fun SectionDivider() {
Box(Modifier.size(0.dp, 24.dp))
}
@Composable
fun ItemDivider() {
HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer)
}
}
/** Similar to items() but includes a horizontal divider between items. */
inline fun <T> LazyListScope.itemsWithDividers(
items: List<T>,
noinline key: ((item: T) -> Any)? = null,
crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) =
items(
count = items.size,
key = if (key != null) { index: Int -> key(items[index]) } else null,
contentType = { index -> contentType(items[index]) }) {
if (it > 0 && it < items.size) {
Lists.ItemDivider()
}
itemContent(items[it])
}

@ -4,15 +4,18 @@
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.view.TailscaleLogoView
import kotlinx.coroutines.flow.MutableStateFlow
object LoadingIndicator {
@ -35,12 +38,12 @@ object LoadingIndicator {
content()
val isLoading = loading.collectAsState().value
if (isLoading) {
Box(Modifier.matchParentSize().background(Color.Gray.copy(alpha = 0.5f)))
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.5f)))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
TailscaleLogoView(true, Modifier.size(72.dp))
}
}
}

@ -6,35 +6,29 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.clickableOrGrayedOut
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -43,16 +37,17 @@ fun ExitNodePicker(
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateHome) }) {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBack) }) {
innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value
val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value
val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value
val anyActive = model.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "runExitNode") {
RunAsExitNodeItem(nav = nav, viewModel = model)
HorizontalDivider()
Lists.SectionDivider()
}
item(key = "none") {
@ -65,48 +60,15 @@ fun ExitNodePicker(
))
}
item { ListHeading(stringResource(R.string.tailnet_exit_nodes)) }
item { Lists.SectionDivider() }
items(tailnetExitNodes.value, key = { it.id!! }) { node ->
ExitNodeItem(model, node, indent = 16.dp)
}
item { ListHeading(stringResource(R.string.mullvad_exit_nodes)) }
itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }
val sortedCountries =
mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
items(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
item { Lists.SectionDivider() }
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast
// to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.
Box {
ListItem(
modifier =
Modifier.padding(start = 16.dp).clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(countryCode)
} else {
model.setExitNode(first)
}
},
headlineContent = { Text("${countryCode.flag()} ${first.country}") },
trailingContent = {
val text = if (nodes.size == 1) first.city else "${nodes.size}"
val icon =
if (nodes.size > 1) Icons.AutoMirrored.Outlined.KeyboardArrowRight
else if (first.selected) Icons.Outlined.Check else null
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
Spacer(modifier = Modifier.width(8.dp))
icon?.let { Icon(it, contentDescription = stringResource(R.string.more)) }
}
})
if (mullvadExitNodeCount > 0) {
item(key = "mullvad") {
MullvadItem(nav, mullvadExitNodeCount, mullvadExitNodesByCountryCode.selected)
}
}
@ -116,36 +78,43 @@ fun ExitNodePicker(
}
}
@Composable
fun ListHeading(label: String, indent: Dp = 0.dp) {
ListItem(
modifier = Modifier.padding(start = indent),
headlineContent = { Text(text = label, style = MaterialTheme.typography.titleMedium) })
}
@Composable
fun ExitNodeItem(
viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode,
indent: Dp = 0.dp
) {
Box {
// TODO: add disabled styling
ListItem(
modifier = Modifier.padding(start = indent).clickable { viewModel.setExitNode(node) },
modifier =
Modifier.clickableOrGrayedOut(enabled = node.online) { viewModel.setExitNode(node) },
headlineContent = { Text(node.city.ifEmpty { node.label }) },
supportingContent = { if (!node.online) Text(stringResource(R.string.offline)) },
trailingContent = {
Row {
if (node.selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.more))
} else if (!node.online) {
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.offline))
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
}
}
})
}
}
@Composable
fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
Box {
ListItem(
modifier = Modifier.clickable { nav.onNavigateToMullvad() },
headlineContent = { Text(stringResource(R.string.mullvad_exit_nodes)) },
supportingContent = { Text("$count ${stringResource(R.string.nodes_available)}") },
trailingContent = {
if (selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
}
})
}
}
@Composable
fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) {
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value

@ -25,8 +25,8 @@ import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
@ -55,8 +55,10 @@ import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow
@ -69,64 +71,67 @@ data class MainViewNavigation(
@Composable
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column(
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
verticalArrangement = Arrangement.Center) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
LoadingIndicator.Wrap {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column(
modifier = Modifier.fillMaxWidth().padding(paddingInsets),
verticalArrangement = Arrangement.Center) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
Row(
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 16.dp)
.padding(top = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp))
}
Row(
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 16.dp)
.padding(top = 10.dp),
verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp))
}
StateDisplay(viewModel.stateRes, viewModel.userName)
StateDisplay(viewModel.stateRes, viewModel.userName)
Box(
modifier = Modifier.weight(1f).clickable { navigation.onNavigateToSettings() },
contentAlignment = Alignment.CenterEnd) {
when (user.value) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else -> Avatar(profile = user.value, size = 36)
Box(
modifier =
Modifier.weight(1f).clickable { navigation.onNavigateToSettings() },
contentAlignment = Alignment.CenterEnd) {
when (user.value) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else -> Avatar(profile = user.value, size = 36)
}
}
}
}
}
when (state.value) {
Ipn.State.Running -> {
when (state.value) {
Ipn.State.Running -> {
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
Row(
modifier =
Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(top = 10.dp, bottom = 20.dp)) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList(
searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
Row(
modifier =
Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(top = 10.dp, bottom = 20.dp)) {
ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
}
PeerList(
searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
}
Ipn.State.NoState,
Ipn.State.Starting -> StartingView()
else ->
ConnectView(
state.value, user.value, { viewModel.toggleVpn() }, { viewModel.login {} })
}
Ipn.State.NoState,
Ipn.State.Starting -> StartingView()
else ->
ConnectView(
state.value, user.value, { viewModel.toggleVpn() }, { viewModel.login {} })
}
}
}
}
}
@ -135,17 +140,10 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID
val exitNode =
exitNodeId?.let { id ->
netmap.value
?.Peers
?.find { it.StableID == id }
?.let { peer ->
peer.Hostinfo.Location?.let { location ->
"${location.Country?.flag()} ${location.Country} - ${location.City}"
} ?: peer.Name
}
}
val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
val location = peer?.Hostinfo?.Location
val name = peer?.Name
Box(
modifier =
Modifier.clickable { navAction() }
@ -153,21 +151,34 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.background)
.fillMaxWidth()) {
Column(modifier = Modifier.padding(vertical = 15.dp, horizontal = 18.dp)) {
Text(
text = stringResource(id = R.string.exit_node),
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = exitNode ?: stringResource(id = R.string.none),
style = MaterialTheme.typography.bodyLarge)
Icon(
Icons.Outlined.ArrowDropDown,
null,
)
}
}
ListItem(
headlineContent = {
Text(
stringResource(R.string.exit_node),
style = MaterialTheme.typography.titleMedium,
)
},
supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
location?.let { "${it.Country?.flag()} ${it.Country} - ${it.City}" }
?: name
?: stringResource(id = R.string.none),
style = MaterialTheme.typography.bodyLarge)
Icon(
Icons.Outlined.ArrowDropDown,
null,
)
}
},
trailingContent = {
if (peer != null) {
Button(onClick = { viewModel.disableExitNode() }) {
Text(stringResource(R.string.disable))
}
}
})
}
}
@ -349,39 +360,36 @@ fun PeerList(
fontWeight = FontWeight.SemiBold)
})
}
peerSet.peers.forEach { peer ->
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 isSelfAndRunning =
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color =
if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green
} else {
Color.Gray
}
Box(
modifier =
Modifier.size(10.dp)
.background(
color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(6.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary)
})
HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer)
}
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
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 isSelfAndRunning =
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color =
if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green
} else {
Color.Gray
}
Box(
modifier =
Modifier.size(10.dp)
.background(
color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(6.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary)
})
}
}
}

@ -5,19 +5,18 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@ -36,26 +35,29 @@ fun MullvadExitNodePicker(
val any = nodes.first()
LoadingIndicator.Wrap {
Scaffold(topBar = { TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") }) }) {
innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
item {
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id,
label = stringResource(R.string.best_available),
online = bestAvailableNode.online,
selected = false,
))
Scaffold(
topBar = {
Header(titleText = "${countryCode.flag()} ${any.country}", onBack = nav.onNavigateBack)
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
item {
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id,
label = stringResource(R.string.best_available),
online = bestAvailableNode.online,
selected = false,
))
Lists.ItemDivider()
}
}
itemsWithDividers(nodes) { node -> ExitNodeItem(model, node) }
}
}
items(nodes) { node -> ExitNodeItem(model, node) }
}
}
}
}
}

@ -0,0 +1,92 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MullvadExitNodePickerList(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBack) }) {
innerPadding ->
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
val sortedCountries =
mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast
// to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.
Box {
ListItem(
modifier =
Modifier.clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(countryCode)
} else {
model.setExitNode(first)
}
},
leadingContent = {
Text(
countryCode.flag(),
style = MaterialTheme.typography.titleLarge,
)
},
headlineContent = { Text(first.country) },
supportingContent = {
Text(
if (nodes.size == 1) first.city
else "${nodes.size} ${stringResource(R.string.cities_available)}")
},
trailingContent = {
if (nodes.size > 1 && nodes.selected || first.selected) {
if (nodes.selected) {
Icon(
Icons.Outlined.Check,
contentDescription = stringResource(R.string.selected))
}
}
})
}
}
}
}
}
}

@ -29,14 +29,14 @@ data class BackNavigation(
// Header view for all secondary screens
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Header(@StringRes title: Int, onBack: (() -> Unit)? = null) {
fun Header(@StringRes title: Int = 0, titleText: String? = null, onBack: (() -> Unit)? = null) {
TopAppBar(
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary,
),
title = { Text(stringResource(title)) },
title = { Text(titleText ?: stringResource(title)) },
navigationIcon = { onBack?.let { BackArrow(action = it) } },
)
}

@ -22,7 +22,9 @@ import java.util.TreeMap
data class ExitNodePickerNav(
val onNavigateHome: () -> Unit,
val onNavigateBack: () -> Unit,
val onNavigateToExitNodePicker: () -> Unit,
val onNavigateToMullvad: () -> Unit,
val onNavigateToMullvadCountry: (String) -> Unit,
val onNavigateToRunAsExitNode: () -> Unit,
)
@ -51,6 +53,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> =
MutableStateFlow(TreeMap())
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
@ -94,12 +97,13 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val tailnetNodes = allNodes.filter { !it.mullvad }
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) })
val allMullvadExitNodes =
allNodes.filter {
// Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online)
}
val mullvadExitNodes =
allNodes
.filter {
// Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online)
}
allMullvadExitNodes
.groupBy {
// Group by countryCode
it.countryCode
@ -127,6 +131,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.sortedBy { it.city.lowercase() }
}
mullvadExitNodesByCountryCode.set(mullvadExitNodes)
mullvadExitNodeCount.set(mullvadExitNodes.size)
val bestAvailableByCountry =
mullvadExitNodes.mapValues { (_, nodes) ->
@ -150,7 +155,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
}
}
fun toggleAllowLANAccess(callback: (Result<Ipn.Prefs>) -> Unit) {
private fun toggleAllowLANAccess(callback: (Result<Ipn.Prefs>) -> Unit) {
val prefs =
Notifier.prefs.value
?: run {
@ -163,3 +168,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
Client(viewModelScope).editPrefs(prefsOut, callback)
}
}
val List<ExitNodePickerViewModel.ExitNode>.selected
get() = this.any { it.selected }
val Map<String, List<ExitNodePickerViewModel.ExitNode>>.selected
get() = this.any { it.value.selected }

@ -5,9 +5,12 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client
import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.set
@ -65,6 +68,13 @@ class MainViewModel : IpnViewModel() {
this.searchTerm.set(searchTerm)
viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(searchTerm)) }
}
fun disableExitNode() {
LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = null
Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() }
}
}
private fun State?.userStringRes(): Int {

@ -12,6 +12,7 @@
<string name="empty"> </string>
<string name="template">%s</string>
<string name="more">More</string>
<string name="selected">Selected</string>
<string name="offline">Offline</string>
<string name="ok">OK</string>
@ -87,6 +88,7 @@
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string>
<string name="choose_mullvad_exit_node">Mullvad Exit Nodes</string>
<string name="tailnet_exit_nodes">Tailnet Exit Nodes</string>
<string name="mullvad_exit_nodes">Mullvad VPN</string>
<string name="best_available">Best Available</string>
@ -100,6 +102,7 @@
<string name="run_exit_node_explainer_running">Other devices in your tailnet can now route their Internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="disable">Disable</string>
<string name="tailnet_lock">Tailnet lock</string>
<string name="tailnet_lock_explainer">Tailnet lock lets devices in your network verify public keys distributed by the coordination server before trusting them for connectivity. </string>
<string name="tailnet_lock_enabled">Tailnet lock is currently enabled.</string>
@ -125,5 +128,7 @@
<string name="this_device_is_using_the_system_dns_resolver">This device is using the system DNS resolver.</string>
<string name="not_using_tailscale_dns">Not Using Tailscale DNS</string>
<string name="allow_lan_access">Allow LAN Access</string>
<string name="nodes_available">nodes available</string>
<string name="cities_available">cities available</string>
</resources>

Loading…
Cancel
Save