Skip to content

Commit

Permalink
android: exit node banner ui improvements
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 21, 2024
1 parent 4fa86db commit 668b287
Show file tree
Hide file tree
Showing 7 changed files with 229 additions and 127 deletions.
85 changes: 85 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ val ColorScheme.off: Color
val ColorScheme.link: Color
get() = onPrimaryContainer

val ColorScheme.exitOff: Color
get() = Color(0xFFEF5350) // red-400

val ColorScheme.exitOffContainer : Color
get() = Color(0xFFB22C30) // red-500

val ColorScheme.runExit: Color
get() = Color(0xFFD97917) // yellow-300

val ColorScheme.runExitContainer : Color
get() = Color(0xFFefc078) // yellow-100

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

/** Color scheme for list items that should be styled as an item with status off. */
val ColorScheme.offListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.exitOff,
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)
}

/** Color scheme for list items that should be styled as an item where an exit node is being run. */
val ColorScheme.runExitNodeListItem: ListItemColors
@Composable
get() {
val default = ListItemDefaults.colors()
return ListItemColors(
containerColor = MaterialTheme.colorScheme.runExit,
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 +334,44 @@ val ColorScheme.secondaryButton: ButtonColors
}
}

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

val ColorScheme.runExitNodeButton: ButtonColors
@Composable
get() {
val defaults = ButtonDefaults.buttonColors()
if (isSystemInDarkTheme()) {
return ButtonColors(
containerColor = Color(0xFFE5993E), // yellow-200
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
95 changes: 73 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 @@ -3,6 +3,7 @@

package com.tailscale.ipn.ui.view

import android.util.Log
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
Expand Down Expand Up @@ -72,10 +73,13 @@ 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.listItem
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.offButton
import com.tailscale.ipn.ui.theme.offListItem
import com.tailscale.ipn.ui.theme.primaryListItem
import com.tailscale.ipn.ui.theme.runExitNodeButton
import com.tailscale.ipn.ui.theme.runExitNodeListItem
import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short
Expand All @@ -85,7 +89,6 @@ 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,11 +205,21 @@ fun MainView(
}
}

enum class NodeState {
NONE,
ACTIVE_AND_RUNNING,
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()

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 +228,27 @@ 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()
LaunchedEffect(exitNodePeer?.Online) {
when {
exitNodePeer?.Online == false -> {
nodeState = NodeState.OFFLINE
if (MDMSettings.exitNodeID.flow.value != null) {
nodeState = NodeState.OFFLINE_MDM
}
}
(exitNodePeer != null) && !prefs.activeExitNodeID.isNullOrEmpty() -> {
nodeState = NodeState.ACTIVE_AND_RUNNING
}
viewModel.isRunningExitNode.value -> {
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 +263,68 @@ 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.RUNNING_AS_EXIT_NODE -> MaterialTheme.colorScheme.runExitNodeListItem
NodeState.OFFLINE -> MaterialTheme.colorScheme.offListItem
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.offListItem
else ->
ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface)
},
overlineContent = {
Text(
stringResource(R.string.exit_node),
text =
if (nodeState == NodeState.OFFLINE || 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 -> MaterialTheme.colorScheme.offButton
NodeState.OFFLINE_MDM -> MaterialTheme.colorScheme.offButton
NodeState.RUNNING_AS_EXIT_NODE ->
MaterialTheme.colorScheme.runExitNodeButton
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 -> stringResource(id = R.string.enable)
NodeState.OFFLINE_MDM -> stringResource(id = R.string.enable)
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 668b287

Please sign in to comment.