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 3 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.MainViewNavigation
import com.tailscale.ipn.ui.view.ManagedByView import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker 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.PeerDetails
import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.Settings import com.tailscale.ipn.ui.view.Settings
@ -96,7 +97,9 @@ class MainActivity : ComponentActivity() {
onNavigateHome = { onNavigateHome = {
navController.popBackStack(route = "main", inclusive = false) navController.popBackStack(route = "main", inclusive = false)
}, },
onNavigateBack = { navController.popBackStack() },
onNavigateToExitNodePicker = { navController.popBackStack() }, onNavigateToExitNodePicker = { navController.popBackStack() },
onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
@ -104,15 +107,14 @@ class MainActivity : ComponentActivity() {
composable("settings") { Settings(settingsNav) } composable("settings") { Settings(settingsNav) }
navigation(startDestination = "list", route = "exitNodes") { navigation(startDestination = "list", route = "exitNodes") {
composable("list") { ExitNodePicker(exitNodePickerNav) } composable("list") { ExitNodePicker(exitNodePickerNav) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable( composable(
"mullvad/{countryCode}", "mullvad/{countryCode}",
arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) { arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker( MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav) it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
} }
composable("runExitNode") { composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
RunExitNodeView(exitNodePickerNav)
}
} }
composable( composable(
"peerDetails/{nodeId}", "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 package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.view.TailscaleLogoView
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
object LoadingIndicator { object LoadingIndicator {
@ -35,12 +38,12 @@ object LoadingIndicator {
content() content()
val isLoading = loading.collectAsState().value val isLoading = loading.collectAsState().value
if (isLoading) { if (isLoading) {
Box(Modifier.matchParentSize().background(Color.Gray.copy(alpha = 0.5f))) Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.5f)))
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) { 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.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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 androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R 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.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.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -43,16 +37,17 @@ fun ExitNodePicker(
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) { ) {
LoadingIndicator.Wrap { 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 -> innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState() val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState() val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value
val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value
val anyActive = model.anyActive.collectAsState() val anyActive = model.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "runExitNode") { item(key = "runExitNode") {
RunAsExitNodeItem(nav = nav, viewModel = model) RunAsExitNodeItem(nav = nav, viewModel = model)
HorizontalDivider() Lists.SectionDivider()
} }
item(key = "none") { 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 -> itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }
ExitNodeItem(model, node, indent = 16.dp)
}
item { ListHeading(stringResource(R.string.mullvad_exit_nodes)) }
val sortedCountries = item { Lists.SectionDivider() }
mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
items(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash if (mullvadExitNodeCount > 0) {
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast item(key = "mullvad") {
// to androidx.compose.runtime.RecomposeScopeImpl MullvadItem(nav, mullvadExitNodeCount, mullvadExitNodesByCountryCode.selected)
// 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)) }
}
})
} }
} }
@ -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 @Composable
fun ExitNodeItem( fun ExitNodeItem(
viewModel: ExitNodePickerViewModel, viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode, node: ExitNodePickerViewModel.ExitNode,
indent: Dp = 0.dp
) { ) {
Box { Box {
// TODO: add disabled styling
ListItem( 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 }) }, headlineContent = { Text(node.city.ifEmpty { node.label }) },
supportingContent = { if (!node.online) Text(stringResource(R.string.offline)) },
trailingContent = { trailingContent = {
Row { Row {
if (node.selected) { if (node.selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.more)) Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
} else if (!node.online) {
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.offline))
} }
} }
}) })
} }
} }
@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 @Composable
fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) { fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) {
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value 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.Close
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem 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.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green 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.PeerSet
import com.tailscale.ipn.ui.util.flag import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -69,64 +71,67 @@ data class MainViewNavigation(
@Composable @Composable
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets -> LoadingIndicator.Wrap {
Column( Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
modifier = Modifier.fillMaxWidth().padding(paddingInsets), Column(
verticalArrangement = Arrangement.Center) { modifier = Modifier.fillMaxWidth().padding(paddingInsets),
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) verticalArrangement = Arrangement.Center) {
val user = viewModel.loggedInUser.collectAsState(initial = null) val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
Row( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(top = 10.dp), .padding(top = 10.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false) val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) { if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp)) Spacer(Modifier.size(3.dp))
} }
StateDisplay(viewModel.stateRes, viewModel.userName) StateDisplay(viewModel.stateRes, viewModel.userName)
Box( Box(
modifier = Modifier.weight(1f).clickable { navigation.onNavigateToSettings() }, modifier =
contentAlignment = Alignment.CenterEnd) { Modifier.weight(1f).clickable { navigation.onNavigateToSettings() },
when (user.value) { contentAlignment = Alignment.CenterEnd) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() } when (user.value) {
else -> Avatar(profile = user.value, size = 36) null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else -> Avatar(profile = user.value, size = 36)
}
} }
} }
}
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
Row( Row(
modifier = modifier =
Modifier.background(MaterialTheme.colorScheme.secondaryContainer) Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(top = 10.dp, bottom = 20.dp)) { .padding(top = 10.dp, bottom = 20.dp)) {
ExitNodeStatus( ExitNodeStatus(
navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
} }
PeerList( PeerList(
searchTerm = viewModel.searchTerm, searchTerm = viewModel.searchTerm,
state = viewModel.ipnState, state = viewModel.ipnState,
peers = viewModel.peers, peers = viewModel.peers,
selfPeer = selfPeerId.value, selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) }) 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 prefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID val exitNodeId = prefs.value?.ExitNodeID
val exitNode = val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } }
exitNodeId?.let { id -> val location = peer?.Hostinfo?.Location
netmap.value val name = peer?.Name
?.Peers
?.find { it.StableID == id }
?.let { peer ->
peer.Hostinfo.Location?.let { location ->
"${location.Country?.flag()} ${location.Country} - ${location.City}"
} ?: peer.Name
}
}
Box( Box(
modifier = modifier =
Modifier.clickable { navAction() } Modifier.clickable { navAction() }
@ -153,21 +151,34 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.fillMaxWidth()) { .fillMaxWidth()) {
Column(modifier = Modifier.padding(vertical = 15.dp, horizontal = 18.dp)) { ListItem(
Text( headlineContent = {
text = stringResource(id = R.string.exit_node), Text(
color = MaterialTheme.colorScheme.secondary, stringResource(R.string.exit_node),
style = MaterialTheme.typography.titleSmall) style = MaterialTheme.typography.titleMedium,
Row(verticalAlignment = Alignment.CenterVertically) { )
Text( },
text = exitNode ?: stringResource(id = R.string.none), supportingContent = {
style = MaterialTheme.typography.bodyLarge) Row(verticalAlignment = Alignment.CenterVertically) {
Icon( Text(
Icons.Outlined.ArrowDropDown, text =
null, 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) fontWeight = FontWeight.SemiBold)
}) })
} }
peerSet.peers.forEach { peer -> itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
item { ListItem(
ListItem( modifier = Modifier.clickable { onNavigateToPeerDetails(peer) },
modifier = Modifier.clickable { onNavigateToPeerDetails(peer) }, headlineContent = {
headlineContent = { Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { // By definition, SelfPeer is online since we will not show the peer list
// By definition, SelfPeer is online since we will not show the peer list // unless you're connected.
// unless you're connected. val isSelfAndRunning =
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
(peer.StableID == selfPeer && stateVal.value == Ipn.State.Running) val color: Color =
val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
if ((peer.Online == true) || isSelfAndRunning) { ts_color_light_green
ts_color_light_green } else {
} else { Color.Gray
Color.Gray }
} Box(
Box( modifier =
modifier = Modifier.size(10.dp)
Modifier.size(10.dp) .background(
.background( color = color, shape = RoundedCornerShape(percent = 50))) {}
color = color, shape = RoundedCornerShape(percent = 50))) {} Spacer(modifier = Modifier.size(6.dp))
Spacer(modifier = Modifier.size(6.dp)) Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) }
} },
}, supportingContent = {
supportingContent = { Text(
Text( text = peer.Addresses?.first()?.split("/")?.first() ?: "",
text = peer.Addresses?.first()?.split("/")?.first() ?: "", style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary)
color = MaterialTheme.colorScheme.secondary) })
})
HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer)
}
} }
} }
} }

@ -5,19 +5,18 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R 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.LoadingIndicator
import com.tailscale.ipn.ui.util.flag 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.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
@ -36,26 +35,29 @@ fun MullvadExitNodePicker(
val any = nodes.first() val any = nodes.first()
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(topBar = { TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") }) }) { Scaffold(
innerPadding -> topBar = {
LazyColumn(modifier = Modifier.padding(innerPadding)) { Header(titleText = "${countryCode.flag()} ${any.country}", onBack = nav.onNavigateBack)
if (nodes.size > 1) { }) { innerPadding ->
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!! LazyColumn(modifier = Modifier.padding(innerPadding)) {
item { if (nodes.size > 1) {
ExitNodeItem( val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
model, item {
ExitNodePickerViewModel.ExitNode( ExitNodeItem(
id = bestAvailableNode.id, model,
label = stringResource(R.string.best_available), ExitNodePickerViewModel.ExitNode(
online = bestAvailableNode.online, id = bestAvailableNode.id,
selected = false, 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 // Header view for all secondary screens
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Header(@StringRes title: Int, onBack: (() -> Unit)? = null) { fun Header(@StringRes title: Int = 0, titleText: String? = null, onBack: (() -> Unit)? = null) {
TopAppBar( TopAppBar(
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary, titleContentColor = MaterialTheme.colorScheme.primary,
), ),
title = { Text(stringResource(title)) }, title = { Text(titleText ?: stringResource(title)) },
navigationIcon = { onBack?.let { BackArrow(action = it) } }, navigationIcon = { onBack?.let { BackArrow(action = it) } },
) )
} }

@ -22,7 +22,9 @@ import java.util.TreeMap
data class ExitNodePickerNav( data class ExitNodePickerNav(
val onNavigateHome: () -> Unit, val onNavigateHome: () -> Unit,
val onNavigateBack: () -> Unit,
val onNavigateToExitNodePicker: () -> Unit, val onNavigateToExitNodePicker: () -> Unit,
val onNavigateToMullvad: () -> Unit,
val onNavigateToMullvadCountry: (String) -> Unit, val onNavigateToMullvadCountry: (String) -> Unit,
val onNavigateToRunAsExitNode: () -> Unit, val onNavigateToRunAsExitNode: () -> Unit,
) )
@ -51,6 +53,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> = val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> =
MutableStateFlow(TreeMap()) MutableStateFlow(TreeMap())
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap()) val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val mullvadExitNodeCount: StateFlow<Int> = MutableStateFlow(0)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false) val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
val isRunningExitNode: 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 } val tailnetNodes = allNodes.filter { !it.mullvad }
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b -> a.label.compareTo(b.label) }) 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 = val mullvadExitNodes =
allNodes allMullvadExitNodes
.filter {
// Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online)
}
.groupBy { .groupBy {
// Group by countryCode // Group by countryCode
it.countryCode it.countryCode
@ -127,6 +131,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.sortedBy { it.city.lowercase() } .sortedBy { it.city.lowercase() }
} }
mullvadExitNodesByCountryCode.set(mullvadExitNodes) mullvadExitNodesByCountryCode.set(mullvadExitNodes)
mullvadExitNodeCount.set(mullvadExitNodes.size)
val bestAvailableByCountry = val bestAvailableByCountry =
mullvadExitNodes.mapValues { (_, nodes) -> 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 = val prefs =
Notifier.prefs.value Notifier.prefs.value
?: run { ?: run {
@ -163,3 +168,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
Client(viewModelScope).editPrefs(prefsOut, callback) 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 androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R 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.Ipn.State
import com.tailscale.ipn.ui.model.StableNodeID import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.notifier.Notifier 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.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
@ -65,6 +68,13 @@ class MainViewModel : IpnViewModel() {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
viewModelScope.launch { peers.set(peerCategorizer.groupedAndFilteredPeers(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 { private fun State?.userStringRes(): Int {

@ -12,6 +12,7 @@
<string name="empty"> </string> <string name="empty"> </string>
<string name="template">%s</string> <string name="template">%s</string>
<string name="more">More</string> <string name="more">More</string>
<string name="selected">Selected</string>
<string name="offline">Offline</string> <string name="offline">Offline</string>
<string name="ok">OK</string> <string name="ok">OK</string>
@ -87,6 +88,7 @@
<!-- Strings for ExitNode picker --> <!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string> <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="tailnet_exit_nodes">Tailnet Exit Nodes</string>
<string name="mullvad_exit_nodes">Mullvad VPN</string> <string name="mullvad_exit_nodes">Mullvad VPN</string>
<string name="best_available">Best Available</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="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="enabled">Enabled</string>
<string name="disabled">Disabled</string> <string name="disabled">Disabled</string>
<string name="disable">Disable</string>
<string name="tailnet_lock">Tailnet lock</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_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> <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="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="not_using_tailscale_dns">Not Using Tailscale DNS</string>
<string name="allow_lan_access">Allow LAN Access</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> </resources>

Loading…
Cancel
Save