@ -52,6 +52,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
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.Netmap
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Permissions
import com.tailscale.ipn.ui.model.Tailcfg
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.disabled
import com.tailscale.ipn.ui.theme.errorButton
import com.tailscale.ipn.ui.theme.errorButton
import com.tailscale.ipn.ui.theme.errorListItem
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.LoadingIndicator
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.IpnViewModel.NodeState
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModel
// Navigation actions for the MainView
// Navigation actions for the MainView
@ -109,7 +112,8 @@ fun MainView(
Column (
Column (
modifier = Modifier . fillMaxWidth ( ) . padding ( paddingInsets ) ,
modifier = Modifier . fillMaxWidth ( ) . padding ( paddingInsets ) ,
verticalArrangement = Arrangement . Center ) {
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.
// until permission has been granted to prepare the VPN.
val isPrepared by viewModel . vpnPrepared . collectAsState ( initial = true )
val isPrepared by viewModel . vpnPrepared . collectAsState ( initial = true )
val isOn by viewModel . vpnToggleState . collectAsState ( initial = false )
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
@Composable
fun ExitNodeStatus ( navAction : ( ) -> Unit , viewModel : MainViewModel ) {
fun ExitNodeStatus ( navAction : ( ) -> Unit , viewModel : MainViewModel ) {
val nodeState by viewModel . nodeState . collectAsState ( )
val maybePrefs by viewModel . prefs . collectAsState ( )
val maybePrefs by viewModel . prefs . collectAsState ( )
val netmap by viewModel . netmap . 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
// There's nothing to render if we haven't loaded the prefs yet
val prefs = maybePrefs ?: return
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 exitNodePeer = chosenExitNodeId ?. let { id -> netmap ?. Peers ?. find { it . StableID == id } }
val name = exitNodePeer ?. ComputedName
val name = exitNodePeer ?. ComputedName
val online = exitNodePeer ?. Online
val managedByOrganization by viewModel . managedByOrganization . collectAsState ( )
LaunchedEffect ( prefs . ExitNodeID , exitNodePeer ?. Online , isRunningExitNode ) {
Box (
when {
modifier =
exitNodePeer ?. Online == false -> {
Modifier . fillMaxWidth ( ) . background ( color = MaterialTheme . colorScheme . surfaceContainer ) ) {
if ( MDMSettings . exitNodeID . flow . value != null ) {
if ( nodeState == NodeState . OFFLINE _MDM ) {
nodeState = NodeState . OFFLINE _MDM
Box (
} else if ( prefs . activeExitNodeID != null ) {
modifier =
nodeState = NodeState . OFFLINE _ENABLED
Modifier . padding ( start = 16. dp , end = 16. dp , top = 56. dp , bottom = 16. dp )
} else {
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
nodeState = NodeState . OFFLINE _DISABLED
. background ( MaterialTheme . colorScheme . customErrorContainer )
}
. fillMaxWidth ( )
}
. align ( Alignment . TopCenter ) ) {
exitNodePeer != null -> {
Column (
if ( ! prefs . activeExitNodeID . isNullOrEmpty ( ) ) {
modifier =
nodeState = NodeState . ACTIVE _AND _RUNNING
Modifier . padding ( start = 16. dp , end = 16. dp , top = 36. dp , bottom = 16. dp ) ) {
} else {
Text (
nodeState = NodeState . ACTIVE _NOT _RUNNING
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
Box (
// find a peer on purpose and render the "No Exit Node" state, however, that should
modifier =
// eventually show up in the UI as an error case so the user knows to pick an available node.
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 ) )
Box ( modifier = Modifier . background ( color = MaterialTheme . colorScheme . surfaceContainer ) ) {
. fillMaxWidth ( ) ) {
Box (
ListItem (
modifier =
modifier = Modifier . clickable { navAction ( ) } ,
Modifier . padding ( start = 16. dp , end = 16. dp , top = 4. dp , bottom = 16. dp )
colors =
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
when ( nodeState ) {
. fillMaxWidth ( ) ) {
NodeState . ACTIVE _AND _RUNNING -> MaterialTheme . colorScheme . primaryListItem
ListItem (
NodeState . ACTIVE _NOT _RUNNING -> MaterialTheme . colorScheme . primaryListItem
modifier = Modifier . clickable { navAction ( ) } ,
NodeState . RUNNING _AS _EXIT _NODE -> MaterialTheme . colorScheme . warningListItem
colors =
NodeState . OFFLINE _ENABLED -> MaterialTheme . colorScheme . errorListItem
when ( nodeState ) {
NodeState . OFFLINE _DISABLED -> MaterialTheme . colorScheme . errorListItem
NodeState . ACTIVE _AND _RUNNING -> MaterialTheme . colorScheme . primaryListItem
NodeState . OFFLINE _MDM -> MaterialTheme . colorScheme . errorListItem
NodeState . ACTIVE _NOT _RUNNING -> MaterialTheme . colorScheme . primaryListItem
else ->
NodeState . RUNNING _AS _EXIT _NODE -> MaterialTheme . colorScheme . warningListItem
ListItemDefaults . colors (
NodeState . OFFLINE _ENABLED -> MaterialTheme . colorScheme . errorListItem
containerColor = MaterialTheme . colorScheme . surface )
NodeState . OFFLINE _DISABLED -> MaterialTheme . colorScheme . errorListItem
} ,
NodeState . OFFLINE _MDM -> MaterialTheme . colorScheme . errorListItem
overlineContent = {
else ->
Text (
ListItemDefaults . colors ( containerColor = MaterialTheme . colorScheme . surface )
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 = {
headlineContent = {
Text (
Row ( verticalAlignment = Alignment . CenterVertically ) {
text =
Text (
if ( nodeState == NodeState . OFFLINE _ENABLED ||
text =
nodeState == NodeState . OFFLINE _DISABLED ||
when ( nodeState ) {
nodeState == NodeState . OFFLINE _MDM )
NodeState . NONE -> stringResource ( id = R . string . none )
stringResource ( R . string . exit _node _offline )
NodeState . RUNNING _AS _EXIT _NODE ->
else stringResource ( R . string . exit _node ) ,
stringResource ( id = R . string . running _exit _node )
style = MaterialTheme . typography . bodySmall ,
else -> name ?: " "
)
} ,
} ,
style = MaterialTheme . typography . bodyMedium ,
headlineContent = {
maxLines = 1 ,
Row ( verticalAlignment = Alignment . CenterVertically ) {
overflow = TextOverflow . Ellipsis )
Text (
Icon (
text =
imageVector = Icons . Outlined . ArrowDropDown ,
when ( nodeState ) {
contentDescription = null ,
NodeState . NONE -> stringResource ( id = R . string . none )
tint =
NodeState . RUNNING _AS _EXIT _NODE ->
if ( nodeState == NodeState . NONE )
stringResource ( id = R . string . running _exit _node )
MaterialTheme . colorScheme . onSurfaceVariant
else -> name ?: " "
else MaterialTheme . colorScheme . onPrimary . copy ( alpha = 0.7f ) ,
} ,
)
style = MaterialTheme . typography . bodyMedium ,
}
maxLines = 1 ,
} ,
overflow = TextOverflow . Ellipsis )
trailingContent = {
Icon (
if ( nodeState != NodeState . NONE ) {
imageVector = Icons . Outlined . ArrowDropDown ,
Button (
contentDescription = null ,
colors =
tint =
when ( nodeState ) {
if ( nodeState == NodeState . NONE )
NodeState . OFFLINE _ENABLED -> MaterialTheme . colorScheme . errorButton
MaterialTheme . colorScheme . onSurfaceVariant
NodeState . OFFLINE _DISABLED -> MaterialTheme . colorScheme . errorButton
else MaterialTheme . colorScheme . onPrimary . copy ( alpha = 0.7f ) ,
NodeState . OFFLINE _MDM -> MaterialTheme . colorScheme . errorButton
)
NodeState . RUNNING _AS _EXIT _NODE ->
}
MaterialTheme . colorScheme . warningButton
} ,
else -> MaterialTheme . colorScheme . secondaryButton
trailingContent = {
} ,
if ( nodeState != NodeState . NONE ) {
onClick = {
Button (
if ( nodeState == NodeState . RUNNING _AS _EXIT _NODE )
colors =
viewModel . setRunningExitNode ( false )
when ( nodeState ) {
else viewModel . toggleExitNode ( )
NodeState . OFFLINE _ENABLED -> MaterialTheme . colorScheme . errorButton
} ) {
NodeState . OFFLINE _DISABLED -> MaterialTheme . colorScheme . errorButton
Text (
NodeState . OFFLINE _MDM -> MaterialTheme . colorScheme . errorButton
when ( nodeState ) {
NodeState . RUNNING _AS _EXIT _NODE ->
NodeState . OFFLINE _DISABLED -> stringResource ( id = R . string . enable )
MaterialTheme . colorScheme . warningButton
NodeState . ACTIVE _NOT _RUNNING ->
else -> MaterialTheme . colorScheme . secondaryButton
stringResource ( id = R . string . enable )
} ,
NodeState . RUNNING _AS _EXIT _NODE ->
onClick = {
stringResource ( id = R . string . stop )
if ( nodeState == NodeState . RUNNING _AS _EXIT _NODE )
else -> stringResource ( id = R . string . disable )
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
@Composable