@ -52,6 +52,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -71,6 +72,7 @@ import com.tailscale.ipn.ui.model.IpnLocal
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.customErrorContainer
import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.errorButton
import com.tailscale.ipn.ui.theme.errorListItem
@ -88,6 +90,7 @@ 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.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel
// Navigation actions for the MainView
@ -109,7 +112,8 @@ fun MainView(
Column (
modifier = Modifier . fillMaxWidth ( ) . padding ( paddingInsets ) ,
verticalArrangement = Arrangement . Center ) {
// Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared cannot be known
// Assume VPN has been prepared for optimistic UI. Whether or not it has been prepared
// cannot be known
// until permission has been granted to prepare the VPN.
val isPrepared by viewModel . vpnPrepared . collectAsState ( initial = true )
val isOn by viewModel . vpnToggleState . collectAsState ( initial = false )
@ -205,28 +209,11 @@ 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 nodeState by viewModel . nodeState . collectAsState ( )
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
@ -238,121 +225,117 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val exitNodePeer = chosenExitNodeId ?. let { id -> netmap ?. Peers ?. find { it . StableID == id } }
val name = exitNodePeer ?. ComputedName
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
val managedByOrganization by viewModel . managedByOrganization . collectAsState ( )
Box (
modifier =
Modifier . fillMaxWidth ( ) . background ( color = MaterialTheme . colorScheme . surfaceContainer ) ) {
if ( nodeState == NodeState . OFFLINE _MDM ) {
Box (
modifier =
Modifier . padding ( start = 16. dp , end = 16. dp , top = 56. dp , bottom = 16. dp )
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
. background ( MaterialTheme . colorScheme . customErrorContainer )
. fillMaxWidth ( )
. align ( Alignment . TopCenter ) ) {
Column (
modifier =
Modifier . padding ( start = 16. dp , end = 16. dp , top = 36. dp , bottom = 16. dp ) ) {
Text (
text =
managedByOrganization ?. let {
stringResource ( R . string . exit _node _offline _mdm _orgname , it )
} ?: stringResource ( R . string . exit _node _offline _mdm ) ,
style = MaterialTheme . typography . bodyMedium ,
color = Color . White )
}
}
}
}
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
// eventually show up in the UI as an error case so the user knows to pick an available node.
Box ( modifier = Modifier . background ( color = MaterialTheme . colorScheme . surfaceContainer ) ) {
Box (
modifier =
Modifier . padding ( start = 16. dp , end = 16. dp , top = 4. dp , bottom = 16. dp )
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
. fillMaxWidth ( ) ) {
ListItem (
modifier = Modifier . clickable { navAction ( ) } ,
colors =
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 )
Box (
modifier =
Modifier . padding ( start = 16. dp , end = 16. dp , top = 4. dp , bottom = 16. dp )
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
. fillMaxWidth ( ) ) {
ListItem (
modifier = Modifier . clickable { navAction ( ) } ,
colors =
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 (
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 ,
)
} ,
overlineContent = {
Text (
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 =
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 ( nodeState == NodeState . NONE )
MaterialTheme . colorScheme . onSurfaceVariant
else MaterialTheme . colorScheme . onPrimary . copy ( alpha = 0.7f ) ,
)
}
} ,
trailingContent = {
if ( nodeState != NodeState . NONE ) {
Button (
colors =
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 (
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 )
} )
}
}
} )
}
}
headlineContent = {
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text (
text =
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 ( nodeState == NodeState . NONE )
MaterialTheme . colorScheme . onSurfaceVariant
else MaterialTheme . colorScheme . onPrimary . copy ( alpha = 0.7f ) ,
)
}
} ,
trailingContent = {
if ( nodeState != NodeState . NONE ) {
Button (
colors =
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 (
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 )
} )
}
}
} )
}
}
}
@Composable