Skip to content

Commit

Permalink
android: exit node banner ui improvements (#408)
Browse files Browse the repository at this point in the history
-show if device is running as an exit node
-show exit node connection status

Updates tailscale/corp#19122

Follow up will include:
-make exit node picker recompose when exit node connection status changes
-prevent user from running as exit node if it is using an exit node and vice versa instead of silently failing
-add explanation box for MDM offline state

Signed-off-by: kari-ts <kari@tailscale.com>
  • Loading branch information
kari-ts committed May 29, 2024
1 parent 57bcf3c commit 29065a0
Show file tree
Hide file tree
Showing 8 changed files with 244 additions and 128 deletions.
72 changes: 71 additions & 1 deletion android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,13 @@ private val DarkColors =
)

val ColorScheme.warning: Color
get() = Color(0xFFD97916) // yellow-300
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFFBB5504) // yellow-400
} else {
Color(0xFFD97917) // yellow-300
}

val ColorScheme.onWarning: Color
get() = Color(0xFFFFFFFF) // white
Expand Down Expand Up @@ -152,6 +158,15 @@ val ColorScheme.off: Color
val ColorScheme.link: Color
get() = onPrimaryContainer

val ColorScheme.customError: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF940821) // red-600
} else {
Color(0xFFB22D30) // red-500
}

/**
* Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons.
*/
Expand Down Expand Up @@ -256,6 +271,23 @@ val ColorScheme.warningListItem: ListItemColors
disabledTrailingIconColor = default.disabledTrailingIconColor)
}

/** Color scheme for list items that should be styled as an error item. */
val ColorScheme.errorListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.customError,
headlineColor = MaterialTheme.colorScheme.onPrimary,
leadingIconColor = MaterialTheme.colorScheme.onPrimary,
overlineColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f),
supportingTextColor = MaterialTheme.colorScheme.onPrimary,
trailingIconColor = MaterialTheme.colorScheme.onPrimary,
disabledHeadlineColor = default.disabledHeadlineColor,
disabledLeadingIconColor = default.disabledLeadingIconColor,
disabledTrailingIconColor = default.disabledTrailingIconColor)
}

/** Main color scheme for top app bar, styles it as a surface container. */
@OptIn(ExperimentalMaterial3Api::class)
val ColorScheme.topAppBar: TopAppBarColors
Expand Down Expand Up @@ -287,6 +319,44 @@ val ColorScheme.secondaryButton: ButtonColors
}
}

val ColorScheme.errorButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFFB22D30), // red-500
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
return ButtonColors(
containerColor = Color(0xFFD04841), // red-400
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}

val ColorScheme.warningButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFFD97917), // yellow-300
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
} else {
return ButtonColors(
containerColor = Color(0xFFE5993E), // yellow-200
contentColor = Color(0xFFFFFFFF), // white
disabledContainerColor = defaults.disabledContainerColor,
disabledContentColor = defaults.disabledContentColor)
}
}

val ColorScheme.defaultTextColor: Color
@Composable
get() =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package com.tailscale.ipn.ui.util

import com.tailscale.ipn.ui.model.Ipn

class AdvertisedRoutesHelper {
companion object {
fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean {
var v4 = false
var v6 = false
prefs.AdvertiseRoutes?.forEach {
if (it == "0.0.0.0/0") {
v4 = true
}
if (it == "::/0") {
v6 = true
}
}
return v4 && v6
}
}
}
116 changes: 94 additions & 22 deletions android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,21 @@ import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.theme.errorButton
import com.tailscale.ipn.ui.theme.errorListItem
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem
import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists
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

Expand Down Expand Up @@ -202,10 +203,28 @@ fun MainView(
}
}

enum class NodeState {
NONE,
ACTIVE_AND_RUNNING,
// Last selected exit node is active but is not being used.
ACTIVE_NOT_RUNNING,
// Last selected exit node is currently offline.
OFFLINE_ENABLED,
// Last selected exit node has been de-selected and is currently offline.
OFFLINE_DISABLED,
// Exit node selection is managed by an administrator, and last selected exit node is currently
// offline
OFFLINE_MDM,
RUNNING_AS_EXIT_NODE
}

@Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val maybePrefs by viewModel.prefs.collectAsState()
val netmap by viewModel.netmap.collectAsState()
val isRunningExitNode by viewModel.isRunningExitNode.collectAsState()

var nodeState by remember { mutableStateOf(NodeState.NONE) }

// There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs ?: return
Expand All @@ -215,11 +234,36 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID

val exitNodePeer = chosenExitNodeId?.let { id -> netmap?.Peers?.find { it.StableID == id } }
val location = exitNodePeer?.Hostinfo?.Location
val name = exitNodePeer?.ComputedName

// We're connected to an exit node if we found an active peer for the *active* exit node
val activeAndRunning = (exitNodePeer != null) && !prefs.activeExitNodeID.isNullOrEmpty()
val online = exitNodePeer?.Online

LaunchedEffect(prefs.ExitNodeID, exitNodePeer?.Online, isRunningExitNode) {
when {
exitNodePeer?.Online == false -> {
if (MDMSettings.exitNodeID.flow.value != null) {
nodeState = NodeState.OFFLINE_MDM
} else if (prefs.activeExitNodeID != null) {
nodeState = NodeState.OFFLINE_ENABLED
} else {
nodeState = NodeState.OFFLINE_DISABLED
}
}
exitNodePeer != null -> {
if (!prefs.activeExitNodeID.isNullOrEmpty()) {
nodeState = NodeState.ACTIVE_AND_RUNNING
} else {
nodeState = NodeState.ACTIVE_NOT_RUNNING
}
}
isRunningExitNode -> {
nodeState = NodeState.RUNNING_AS_EXIT_NODE
}
else -> {
nodeState = NodeState.NONE
}
}
}

// (jonathan) TODO: We will block the "enable/disable" button for an exit node for which we cannot
// find a peer on purpose and render the "No Exit Node" state, however, that should
Expand All @@ -234,46 +278,74 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
ListItem(
modifier = Modifier.clickable { navAction() },
colors =
if (activeAndRunning) MaterialTheme.colorScheme.primaryListItem
else ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface),
when (nodeState) {
NodeState.ACTIVE_AND_RUNNING -> MaterialTheme.colorScheme.primaryListItem
NodeState.ACTIVE_NOT_RUNNING -> MaterialTheme.colorScheme.primaryListItem
NodeState.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.warningListItem
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorListItem
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorListItem
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorListItem
else ->
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)
},
overlineContent = {
Text(
stringResource(R.string.exit_node),
text =
if (nodeState == NodeState.OFFLINE_ENABLED ||
nodeState == NodeState.OFFLINE_DISABLED ||
nodeState == NodeState.OFFLINE_MDM)
stringResource(R.string.exit_node_offline)
else stringResource(R.string.exit_node),
style = MaterialTheme.typography.bodySmall,
)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
location?.let { "${it.CountryCode?.flag()} ${it.Country}: ${it.City}" }
?: name
?: stringResource(id = R.string.none),
when (nodeState) {
NodeState.NONE -> stringResource(id = R.string.none)
NodeState.RUNNING_AS_EXIT_NODE ->
stringResource(id = R.string.running_exit_node)
else -> name ?: ""
},
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Icon(
imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null,
tint =
if (activeAndRunning)
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant,
if (nodeState == NodeState.NONE)
MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
)
}
},
trailingContent = {
if (exitNodePeer != null) {
if (nodeState != NodeState.NONE) {
Button(
colors =
if (prefs.activeExitNodeID.isNullOrEmpty())
MaterialTheme.colorScheme.exitNodeToggleButton
else MaterialTheme.colorScheme.secondaryButton,
onClick = { viewModel.toggleExitNode() }) {
when (nodeState) {
NodeState.OFFLINE_ENABLED -> MaterialTheme.colorScheme.errorButton
NodeState.OFFLINE_DISABLED -> MaterialTheme.colorScheme.errorButton
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.errorButton
NodeState.RUNNING_AS_EXIT_NODE ->
MaterialTheme.colorScheme.warningButton
else -> MaterialTheme.colorScheme.secondaryButton
},
onClick = {
if (nodeState == NodeState.RUNNING_AS_EXIT_NODE)
viewModel.setRunningExitNode(false)
else viewModel.toggleExitNode()
}) {
Text(
if (prefs.activeExitNodeID.isNullOrEmpty())
stringResource(id = R.string.enable)
else stringResource(id = R.string.disable))
when (nodeState) {
NodeState.OFFLINE_DISABLED -> stringResource(id = R.string.enable)
NodeState.ACTIVE_NOT_RUNNING -> stringResource(id = R.string.enable)
NodeState.RUNNING_AS_EXIT_NODE -> stringResource(id = R.string.stop)
else -> stringResource(id = R.string.disable)
})
}
}
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModel
import com.tailscale.ipn.ui.viewModel.RunExitNodeViewModelFactory
import com.tailscale.ipn.ui.viewModel.IpnViewModel

@Composable
fun RunExitNodeView(
nav: ExitNodePickerNav,
model: RunExitNodeViewModel = viewModel(factory = RunExitNodeViewModelFactory())
) {
fun RunExitNodeView(nav: ExitNodePickerNav, model: IpnViewModel = viewModel()) {
val isRunningExitNode by model.isRunningExitNode.collectAsState()

Scaffold(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,13 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
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)

init {
viewModelScope.launch {
Notifier.netmap
.combine(Notifier.prefs) { netmap, prefs -> Pair(netmap, prefs) }
.stateIn(viewModelScope)
.collect { (netmap, prefs) ->
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) })
val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
netmap?.Peers?.let { peers ->
val allNodes =
Expand Down
Loading

0 comments on commit 29065a0

Please sign in to comment.