diff --git a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt index 84ef321..ff45c27 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt @@ -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. */ diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index 143deda..3763a8b 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -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( diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index 710bbb7..eaaefd2 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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 diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index d2b8b46..e0e8be3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -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 = 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 + 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") } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index ff36191..f6d36eb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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 = 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() { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt index a1ef0d1..c642329 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/PeerDetailsViewModel.kt @@ -27,7 +27,6 @@ class PeerDetailsViewModelFactory(private val nodeId: StableNodeID, private val } class PeerDetailsViewModel(val nodeId: StableNodeID, val filesDir: File) : IpnViewModel() { - val netmap: StateFlow = MutableStateFlow(null) val node: StateFlow = MutableStateFlow(null) init { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index fbdcec0..7d64fbd 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -29,7 +29,6 @@ data class SettingsNav( class SettingsViewModel : IpnViewModel() { // Display name for the logged in user val isAdmin: StateFlow = MutableStateFlow(false) - val managedByOrganization = MDMSettings.managedByOrganizationName.flow // True if tailnet lock is enabled. nil if not yet known. val tailNetLockEnabled: StateFlow = MutableStateFlow(null) // True if tailscaleDNS is enabled. nil if not yet known. diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index ec01141..54587e6 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -53,6 +53,8 @@ EXIT NODE EXIT NODE OFFLINE Running on this device + The exit node selected by %1$s is currently offline. Internet traffic is blocked. Contact a network administrator for help. + The exit node selected by your organization is currently offline. Internet traffic is blocked. Contact a network administrator for help. Starting… "Connect again to talk to the other devices in the " " tailnet." @@ -142,6 +144,8 @@ 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. Enabled Disabled + %1$s requires you to use an exit node. + Your organization requires you to use an exit node. Tailnet lock