Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

android: exit node banner ui improvements #408

Merged
merged 1 commit into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading