From 29065a0b3158314593eeebcddfb9600bb06df641 Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Wed, 22 May 2024 15:06:36 -0700 Subject: [PATCH] android: exit node banner ui improvements (#408) -show if device is running as an exit node -show exit node connection status Updates tailscale/corp#19122 Follow up will include: -make exit node picker recompose when exit node connection status changes -prevent user from running as exit node if it is using an exit node and vice versa instead of silently failing -add explanation box for MDM offline state Signed-off-by: kari-ts --- .../java/com/tailscale/ipn/ui/theme/Theme.kt | 72 ++++++++++- .../ipn/ui/util/AdvertisedRoutesHelper.kt | 24 ++++ .../com/tailscale/ipn/ui/view/MainView.kt | 116 ++++++++++++++---- .../tailscale/ipn/ui/view/RunExitNodeView.kt | 8 +- .../ui/viewModel/ExitNodePickerViewModel.kt | 2 - .../ipn/ui/viewModel/IpnViewModel.kt | 50 ++++++++ .../ipn/ui/viewModel/RunExitNodeViewModel.kt | 97 --------------- android/src/main/res/values/strings.xml | 3 + 8 files changed, 244 insertions(+), 128 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt delete mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/RunExitNodeViewModel.kt 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 38f00e8162..84ef321c92 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 @@ -114,7 +114,13 @@ private val DarkColors = ) val ColorScheme.warning: Color - get() = Color(0xFFD97916) // yellow-300 + @Composable + get() = + if (isSystemInDarkTheme()) { + Color(0xFFBB5504) // yellow-400 + } else { + Color(0xFFD97917) // yellow-300 + } val ColorScheme.onWarning: Color get() = Color(0xFFFFFFFF) // white @@ -152,6 +158,15 @@ val ColorScheme.off: Color val ColorScheme.link: Color get() = onPrimaryContainer +val ColorScheme.customError: Color + @Composable + get() = + if (isSystemInDarkTheme()) { + Color(0xFF940821) // red-600 + } else { + Color(0xFFB22D30) // red-500 + } + /** * Main color scheme for list items, uses onPrimaryContainer color for leading and trailing icons. */ @@ -256,6 +271,23 @@ val ColorScheme.warningListItem: ListItemColors disabledTrailingIconColor = default.disabledTrailingIconColor) } +/** Color scheme for list items that should be styled as an error item. */ +val ColorScheme.errorListItem: ListItemColors + @Composable + get() { + val default = ListItemDefaults.colors() + return ListItemColors( + containerColor = MaterialTheme.colorScheme.customError, + 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 @@ -287,6 +319,44 @@ val ColorScheme.secondaryButton: ButtonColors } } +val ColorScheme.errorButton: ButtonColors + @Composable + get() { + val defaults = ButtonDefaults.buttonColors() + if (isSystemInDarkTheme()) { + return ButtonColors( + containerColor = Color(0xFFB22D30), // red-500 + contentColor = Color(0xFFFFFFFF), // white + disabledContainerColor = defaults.disabledContainerColor, + disabledContentColor = defaults.disabledContentColor) + } else { + return ButtonColors( + containerColor = Color(0xFFD04841), // red-400 + contentColor = Color(0xFFFFFFFF), // white + disabledContainerColor = defaults.disabledContainerColor, + disabledContentColor = defaults.disabledContentColor) + } + } + +val ColorScheme.warningButton: ButtonColors + @Composable + get() { + val defaults = ButtonDefaults.buttonColors() + if (isSystemInDarkTheme()) { + return ButtonColors( + containerColor = Color(0xFFD97917), // yellow-300 + 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() = diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt b/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt new file mode 100644 index 0000000000..d97da48045 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/AdvertisedRoutesHelper.kt @@ -0,0 +1,24 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.util + +import com.tailscale.ipn.ui.model.Ipn + +class AdvertisedRoutesHelper { + companion object { + fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean { + var v4 = false + var v6 = false + prefs.AdvertiseRoutes?.forEach { + if (it == "0.0.0.0/0") { + v4 = true + } + if (it == "::/0") { + v6 = true + } + } + return v4 && v6 + } + } +} \ No newline at end of file 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 1a4ea2eb72..50962229ad 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 @@ -72,7 +72,8 @@ 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.errorButton +import com.tailscale.ipn.ui.theme.errorListItem import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.minTextSize import com.tailscale.ipn.ui.theme.primaryListItem @@ -80,12 +81,12 @@ import com.tailscale.ipn.ui.theme.searchBarColors import com.tailscale.ipn.ui.theme.secondaryButton import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.surfaceContainerListItem +import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningListItem 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 @@ -202,10 +203,28 @@ 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 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 @@ -215,11 +234,36 @@ 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() + 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 + } + } + 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 @@ -234,11 +278,24 @@ 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.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( - stringResource(R.string.exit_node), + 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, ) }, @@ -246,9 +303,12 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { 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) @@ -256,24 +316,36 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { 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_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( - if (prefs.activeExitNodeID.isNullOrEmpty()) - stringResource(id = R.string.enable) - else stringResource(id = R.string.disable)) + 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) + }) } } }) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt index 3ef78aa58f..4b600d386f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt @@ -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( diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index 7b2649d035..2d5eb40559 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -54,7 +54,6 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel val mullvadBestAvailableByCountry: StateFlow> = MutableStateFlow(TreeMap()) val mullvadExitNodeCount: StateFlow = MutableStateFlow(0) val anyActive: StateFlow = MutableStateFlow(false) - val isRunningExitNode: StateFlow = MutableStateFlow(false) init { viewModelScope.launch { @@ -62,7 +61,6 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel .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 = 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 294bd0a594..8498c39795 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 @@ -16,6 +16,7 @@ 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 @@ -31,12 +32,16 @@ open class IpnViewModel : ViewModel() { val loggedInUser: StateFlow = MutableStateFlow(null) val loginProfiles: StateFlow?> = MutableStateFlow(null) + private val _vpnPrepared = MutableStateFlow(false) val vpnPrepared: StateFlow = _vpnPrepared // The userId associated with the current node. ie: The logged in user. private var selfNodeUserId: UserID? = null + val isRunningExitNode: StateFlow = MutableStateFlow(false) + var lastPrefs: Ipn.Prefs? = null + init { // Check if the user has granted permission yet. if (!vpnPrepared.value) { @@ -68,11 +73,19 @@ open class IpnViewModel : ViewModel() { } } + viewModelScope.launch { + Notifier.prefs.collect { + it?.let { lastPrefs = it } + isRunningExitNode.set(it?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) }) + } + } + viewModelScope.launch { loadUserProfiles() } Log.d(TAG, "Created") } // VPN Control + fun setVpnPrepared(prepared: Boolean) { _vpnPrepared.value = prepared } @@ -223,4 +236,41 @@ open class IpnViewModel : ViewModel() { Log.e(TAG, "No exit node to disable and no prior exit node to enable") } } + + fun setRunningExitNode(isOn: Boolean) { + LoadingIndicator.start() + lastPrefs?.let { currentPrefs -> + val newPrefs: Ipn.MaskedPrefs + if (isOn) { + newPrefs = setZeroRoutes(currentPrefs) + } else { + newPrefs = removeAllZeroRoutes(currentPrefs) + } + Client(viewModelScope).editPrefs(newPrefs) { result -> + LoadingIndicator.stop() + Log.d("RunExitNodeViewModel", "Edited prefs: $result") + } + } + } + + private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs { + val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList() + newRoutes.add("0.0.0.0/0") + newRoutes.add("::/0") + val newPrefs = Ipn.MaskedPrefs() + newPrefs.AdvertiseRoutes = newRoutes + return newPrefs + } + + private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs { + val newRoutes = emptyList().toMutableList() + (prefs.AdvertiseRoutes ?: emptyList()).forEach { + if (it != "0.0.0.0/0" && it != "::/0") { + newRoutes.add(it) + } + } + val newPrefs = Ipn.MaskedPrefs() + newPrefs.AdvertiseRoutes = newRoutes + return newPrefs + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/RunExitNodeViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/RunExitNodeViewModel.kt deleted file mode 100644 index f847b8d841..0000000000 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/RunExitNodeViewModel.kt +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package com.tailscale.ipn.ui.viewModel - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.tailscale.ipn.ui.localapi.Client -import com.tailscale.ipn.ui.model.Ipn -import com.tailscale.ipn.ui.notifier.Notifier -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.stateIn -import kotlinx.coroutines.launch - -class RunExitNodeViewModelFactory() : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return RunExitNodeViewModel() as T - } -} - -class AdvertisedRoutesHelper() { - companion object { - fun exitNodeOnFromPrefs(prefs: Ipn.Prefs): Boolean { - var v4 = false - var v6 = false - prefs.AdvertiseRoutes?.forEach { - if (it == "0.0.0.0/0") { - v4 = true - } - if (it == "::/0") { - v6 = true - } - } - return v4 && v6 - } - } -} - -class RunExitNodeViewModel() : IpnViewModel() { - - val isRunningExitNode: StateFlow = MutableStateFlow(false) - var lastPrefs: Ipn.Prefs? = null - - init { - viewModelScope.launch { - Notifier.prefs.stateIn(viewModelScope).collect { prefs -> - Log.d("RunExitNode", "prefs: AdvertiseRoutes=" + prefs?.AdvertiseRoutes.toString()) - prefs?.let { - lastPrefs = it - isRunningExitNode.set(AdvertisedRoutesHelper.exitNodeOnFromPrefs(it)) - } ?: run { isRunningExitNode.set(false) } - } - } - } - - fun setRunningExitNode(isOn: Boolean) { - LoadingIndicator.start() - lastPrefs?.let { currentPrefs -> - val newPrefs: Ipn.MaskedPrefs - if (isOn) { - newPrefs = setZeroRoutes(currentPrefs) - } else { - newPrefs = removeAllZeroRoutes(currentPrefs) - } - Client(viewModelScope).editPrefs(newPrefs) { result -> - LoadingIndicator.stop() - Log.d("RunExitNodeViewModel", "Edited prefs: $result") - } - } - } - - private fun setZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs { - val newRoutes = (removeAllZeroRoutes(prefs).AdvertiseRoutes ?: emptyList()).toMutableList() - newRoutes.add("0.0.0.0/0") - newRoutes.add("::/0") - val newPrefs = Ipn.MaskedPrefs() - newPrefs.AdvertiseRoutes = newRoutes - return newPrefs - } - - private fun removeAllZeroRoutes(prefs: Ipn.Prefs): Ipn.MaskedPrefs { - val newRoutes = emptyList().toMutableList() - (prefs.AdvertiseRoutes ?: emptyList()).forEach { - if (it != "0.0.0.0/0" && it != "::/0") { - newRoutes.add(it) - } - } - val newPrefs = Ipn.MaskedPrefs() - newPrefs.AdvertiseRoutes = newRoutes - return newPrefs - } -} diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 682419d442..ec0114144f 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -51,6 +51,8 @@ EXIT NODE + EXIT NODE OFFLINE + Running on this device Starting… "Connect again to talk to the other devices in the " " tailnet." @@ -67,6 +69,7 @@ Authorization required Disable Enable + Stop OS