android: add MDM info to exit node picker and banner (#414)

android: add MDM information in exit node banner and picker

Updates tailscale/corp#19122

Signed-off-by: kari-ts <kari@tailscale.com>
pull/416/head
kari-ts 6 months ago committed by GitHub
parent 8f62f0da79
commit 15da8f3797
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -162,11 +162,20 @@ val ColorScheme.customError: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF940821) // red-600
Color(0xFF940821) // red-600
} else {
Color(0xFFB22D30) // red-500
}
val ColorScheme.customErrorContainer: Color
@Composable
get() =
if (isSystemInDarkTheme()) {
Color(0xFF760012) // red-700
} else {
Color(0xFF940821) // red-600
}
/**
* Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons.
*/

@ -18,8 +18,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.MDMSettings
@ -27,6 +29,7 @@ import com.tailscale.ipn.mdm.ShowHide
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.disabledListItem
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.off
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.itemsWithDividers
@ -51,17 +54,27 @@ fun ExitNodePicker(
val allowLANAccess = Notifier.prefs.collectAsState().value?.ExitNodeAllowLANAccess == true
val showRunAsExitNode by MDMSettings.runExitNode.flow.collectAsState()
val allowLanAccessMDMDisposition by MDMSettings.exitNodeAllowLANAccess.flow.collectAsState()
val managedByOrganization by model.managedByOrganization.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "header") {
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = MutableStateFlow(true),
selected = !anyActive,
))
if (MDMSettings.exitNodeID.flow.value != null){
Text(
text =
managedByOrganization?.let {
stringResource(R.string.exit_node_mdm_orgname, it)
} ?: stringResource(R.string.exit_node_mdm) ,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp))
ExitNodeItem(
model,
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = MutableStateFlow(true),
selected = !anyActive,
))
}
if (showRunAsExitNode == ShowHide.Show) {
Lists.ItemDivider()
RunAsExitNodeItem(nav = nav, viewModel = model, anyActive)
@ -105,7 +118,7 @@ fun ExitNodeItem(
Box {
var modifier: Modifier = Modifier
if (online && !isRunningExitNode) {
if (online && !isRunningExitNode && MDMSettings.exitNodeID.flow.value != null) {
modifier = modifier.clickable { viewModel.setExitNode(node) }
}
ListItem(

@ -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

@ -15,12 +15,12 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.UserID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.notifier.Notifier.prefs
import com.tailscale.ipn.ui.util.AdvertisedRoutesHelper
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
/**
@ -40,7 +40,28 @@ open class IpnViewModel : ViewModel() {
private var selfNodeUserId: UserID? = null
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
var lastPrefs: Ipn.Prefs? = null
private var lastPrefs: Ipn.Prefs? = null
val prefs = Notifier.prefs
val netmap = Notifier.netmap
private val _nodeState = MutableStateFlow(NodeState.NONE)
val nodeState: StateFlow<NodeState> = _nodeState
val managedByOrganization = MDMSettings.managedByOrganizationName.flow
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
}
init {
// Check if the user has granted permission yet.
@ -83,6 +104,44 @@ open class IpnViewModel : ViewModel() {
}
viewModelScope.launch { loadUserProfiles() }
viewModelScope.launch {
combine(prefs, netmap, isRunningExitNode) { prefs, netmap, isRunningExitNode ->
// Handle nullability for prefs and netmap
val validPrefs = prefs ?: return@combine NodeState.NONE
val validNetmap = netmap ?: return@combine NodeState.NONE
val chosenExitNodeId = validPrefs.activeExitNodeID ?: validPrefs.selectedExitNodeID
val exitNodePeer =
chosenExitNodeId?.let { id -> validNetmap.Peers?.find { it.StableID == id } }
when {
exitNodePeer?.Online == false -> {
if (MDMSettings.exitNodeID.flow.value != null) {
NodeState.OFFLINE_MDM
} else if (validPrefs.activeExitNodeID != null) {
NodeState.OFFLINE_ENABLED
} else {
NodeState.OFFLINE_DISABLED
}
}
exitNodePeer != null -> {
if (!validPrefs.activeExitNodeID.isNullOrEmpty()) {
NodeState.ACTIVE_AND_RUNNING
} else {
NodeState.ACTIVE_NOT_RUNNING
}
}
isRunningExitNode == true -> {
NodeState.RUNNING_AS_EXIT_NODE
}
else -> {
NodeState.NONE
}
}
}
.collect { nodeState -> _nodeState.value = nodeState }
}
Log.d(TAG, "Created")
}

@ -5,7 +5,6 @@ package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.VpnService
import android.util.Log
import androidx.activity.result.ActivityResultLauncher
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.App
@ -42,9 +41,6 @@ class MainViewModel : IpnViewModel() {
// The current state of the IPN for determining view visibility
val ipnState = Notifier.state
val prefs = Notifier.prefs
val netmap = Notifier.netmap
// The active search term for filtering peers
val searchTerm: StateFlow<String> = MutableStateFlow("")
@ -97,10 +93,6 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) }
}
viewModelScope.launch {
Notifier.prefs.collect { prefs -> Log.d(TAG, "Main VM - prefs = ${prefs}") }
}
}
fun showVPNPermissionLauncherIfUnauthorized() {

@ -27,7 +27,6 @@ class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val
}
class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() {
val netmap: StateFlow<Netmap.NetworkMap?> = MutableStateFlow(null)
val node: StateFlow<Tailcfg.Node?> = MutableStateFlow(null)
init {

@ -29,7 +29,6 @@ data class SettingsNav(
class SettingsViewModel : IpnViewModel() {
// Display name for the logged in user
val isAdmin: StateFlow<Boolean> = MutableStateFlow(false)
val managedByOrganization = MDMSettings.managedByOrganizationName.flow
// True if tailnet lock is enabled. nil if not yet known.
val tailNetLockEnabled: StateFlow<Boolean?> = MutableStateFlow(null)
// True if tailscaleDNS is enabled. nil if not yet known.

@ -53,6 +53,8 @@
<string name="exit_node">EXIT NODE</string>
<string name="exit_node_offline">EXIT NODE OFFLINE</string>
<string name="running_exit_node">Running on this device</string>
<string name="exit_node_offline_mdm_orgname">The exit node selected by %1$s is currently offline. Internet traffic is blocked. Contact a network administrator for help.</string>
<string name="exit_node_offline_mdm">The exit node selected by your organization is currently offline. Internet traffic is blocked. Contact a network administrator for help.</string>
<string name="starting">Starting…</string>
<string name="connect_to_tailnet_prefix">"Connect again to talk to the other devices in the "</string>
<string name="connect_to_tailnet_suffix">" tailnet."</string>
@ -142,6 +144,8 @@
<string name="run_exit_node_explainer_running">Other devices in your tailnet can now route their internet traffic through this Android device. Make sure to approve this exit node in the admin console in order for other devices to see it.</string>
<string name="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="exit_node_mdm_orgname">%1$s requires you to use an exit node.</string>
<string name="exit_node_mdm">Your organization requires you to use an exit node.</string>
<!-- Strings for the tailnet lock screen -->
<string name="tailnet_lock">Tailnet lock</string>

Loading…
Cancel
Save