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

ExitNodePicker: recompose when connection status changes #409

Closed
wants to merge 2 commits into from
Closed
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
Expand All @@ -40,7 +43,7 @@ fun ExitNodePicker(
nav: ExitNodePickerNav,
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBackHome) }) {
innerPadding ->
val tailnetExitNodes by model.tailnetExitNodes.collectAsState()
Expand Down Expand Up @@ -99,21 +102,23 @@ fun ExitNodeItem(
viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode,
) {
val online by rememberUpdatedState(newValue = node.online)

Box {
var modifier: Modifier = Modifier
if (node.online) {
if (online) {
modifier = modifier.clickable { viewModel.setExitNode(node) }
}
ListItem(
modifier = modifier,
colors =
if (node.online) MaterialTheme.colorScheme.listItem
if (online) MaterialTheme.colorScheme.listItem
else MaterialTheme.colorScheme.disabledListItem,
headlineContent = {
Text(node.city.ifEmpty { node.label }, style = MaterialTheme.typography.bodyMedium)
},
supportingContent = {
if (!node.online)
if (!online)
Text(stringResource(R.string.offline), style = MaterialTheme.typography.bodyMedium)
},
trailingContent = {
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
Loading