From f4d2a277a57807c09560b220b576e1070e4caea2 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Thu, 18 Apr 2024 15:12:56 -0400 Subject: [PATCH] android/ui: add support for remembering the last used exit node (#305) Updates ENG-2911 Disabling an exit node is now temporary and you can re-enable it without re-selecting it from the picker. Signed-off-by: Jonathan Nobels --- .../com/tailscale/ipn/ui/localapi/Client.kt | 6 +++ .../java/com/tailscale/ipn/ui/model/Ipn.kt | 24 ++++++++- .../java/com/tailscale/ipn/ui/theme/Theme.kt | 11 ++++ .../com/tailscale/ipn/ui/view/MainView.kt | 52 +++++++++++++------ .../ui/viewModel/ExitNodePickerViewModel.kt | 5 +- .../ipn/ui/viewModel/IpnViewModel.kt | 20 +++++++ .../ipn/ui/viewModel/MainViewModel.kt | 15 ++---- android/src/main/res/values/strings.xml | 3 +- go.mod | 4 +- go.sum | 8 +-- 10 files changed, 112 insertions(+), 36 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index 10729a6..15d8c55 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -46,6 +46,7 @@ private object Endpoint { const val FILES = "files" const val FILE_PUT = "file-put" const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address" + const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled" } typealias StatusResponseHandler = (Result) -> Unit @@ -85,6 +86,11 @@ class Client(private val scope: CoroutineScope) { return patch(Endpoint.PREFS, body, responseHandler = responseHandler) } + fun setUseExitNode(use: Boolean, responseHandler: (Result) -> Unit) { + val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use" + return post(path, responseHandler = responseHandler) + } + fun profiles(responseHandler: (Result>) -> Unit) { get(Endpoint.PROFILES, responseHandler = responseHandler) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 087e0d5..883fa98 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -65,7 +65,22 @@ class Ipn { var ForceDaemon: Boolean = false, var HostName: String = "", var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true), - ) + var InternalExitNodePrior: String? = null, + ) { + + // For the InternalExitNodePrior and ExitNodeId, these will treats the empty string as null to + // simplify the downstream logic. + + val selectedExitNodeID: String? + get() { + return if (InternalExitNodePrior.isNullOrEmpty()) null else InternalExitNodePrior + } + + val activeExitNodeID: String? + get() { + return if (ExitNodeID.isNullOrEmpty()) null else ExitNodeID + } + } @Serializable data class MaskedPrefs( @@ -79,6 +94,7 @@ class Ipn { var AdvertiseRoutesSet: Boolean? = null, var ForceDaemonSet: Boolean? = null, var HostnameSet: Boolean? = null, + var InternalExitNodePriorSet: Boolean? = null, ) { var ControlURL: String? = null @@ -105,6 +121,12 @@ class Ipn { ExitNodeIDSet = true } + var InternalExitNodePrior: String? = null + set(value) { + field = value + InternalExitNodePriorSet = true + } + var ExitNodeAllowLANAccess: Boolean? = null set(value) { field = value 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 2269667..273df4e 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 @@ -333,6 +333,17 @@ val ColorScheme.onBackgroundLogoDotDisabled: Color Color(0x66FFFFFF) } +val ColorScheme.exitNodeToggleButton: ButtonColors + @Composable + get() { + val defaults = ButtonDefaults.buttonColors() + return ButtonColors( + containerColor = Color(0xFF4B70CC), // blue-500 + contentColor = Color(0xFFFFFFFF), // white + disabledContainerColor = defaults.disabledContainerColor, + disabledContentColor = defaults.disabledContentColor) + } + val ColorScheme.disabled: Color get() = Color(0xFFAFACAB) // gray-400 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 5c97955..b851102 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 @@ -70,6 +70,7 @@ 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.listItem import com.tailscale.ipn.ui.theme.minTextSize import com.tailscale.ipn.ui.theme.primaryListItem @@ -182,13 +183,26 @@ fun MainView( @Composable fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { - val prefs = viewModel.prefs.collectAsState() + val maybePrefs = viewModel.prefs.collectAsState() val netmap = viewModel.netmap.collectAsState() - val exitNodeId = prefs.value?.ExitNodeID - val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } } - val location = peer?.Hostinfo?.Location - val name = peer?.ComputedName - val active = peer != null + + // There's nothing to render if we haven't loaded the prefs yet + val prefs = maybePrefs.value ?: return + + // The activeExitNode is the source of truth. The selectedExitNode is only relevant if we + // don't have an active node. + val chosenExitNodeId = prefs.activeExitNodeID ?: prefs.selectedExitNodeID + + val exitNodePeer = chosenExitNodeId?.let { id -> netmap.value?.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() + + // (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( @@ -199,7 +213,7 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { ListItem( modifier = Modifier.clickable { navAction() }, colors = - if (active) MaterialTheme.colorScheme.primaryListItem + if (activeAndRunning) MaterialTheme.colorScheme.primaryListItem else ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surface), overlineContent = { Text( @@ -221,17 +235,24 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null, tint = - if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) + if (activeAndRunning) + MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) else MaterialTheme.colorScheme.onSurfaceVariant, ) } }, trailingContent = { - if (peer != null) { + if (exitNodePeer != null) { Button( - colors = MaterialTheme.colorScheme.secondaryButton, - onClick = { viewModel.disableExitNode() }) { - Text(stringResource(R.string.stop)) + colors = + if (prefs.activeExitNodeID.isNullOrEmpty()) + MaterialTheme.colorScheme.exitNodeToggleButton + else MaterialTheme.colorScheme.secondaryButton, + onClick = { viewModel.toggleExitNode() }) { + Text( + if (prefs.activeExitNodeID.isNullOrEmpty()) + stringResource(id = R.string.enable) + else stringResource(id = R.string.disable)) } } }) @@ -470,7 +491,8 @@ fun PeerList( fun ExpiryNotificationIfNecessary(netmap: Netmap.NetworkMap?, action: () -> Unit = {}) { // Key expiry warning shown only if the key is expiring within 24 hours (or has already expired) val networkMap = netmap ?: return - if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry) || networkMap.SelfNode.keyDoesNotExpire) { + if (!TimeUtil.isWithin24Hours(networkMap.SelfNode.KeyExpiry) || + networkMap.SelfNode.keyDoesNotExpire) { return } @@ -518,8 +540,6 @@ fun MainViewPreview() { MainView( {}, MainViewNavigation( - onNavigateToSettings = {}, - onNavigateToPeerDetails = {}, - onNavigateToExitNodes = {}), + onNavigateToSettings = {}, onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}), vm) } 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 2f400de..65711d6 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 @@ -63,7 +63,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel .stateIn(viewModelScope) .collect { (netmap, prefs) -> isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) }) - val exitNodeId = prefs?.ExitNodeID + val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID netmap?.Peers?.let { peers -> val allNodes = peers @@ -137,8 +137,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel LoadingIndicator.start() val prefsOut = Ipn.MaskedPrefs() prefsOut.ExitNodeID = node.id + Client(viewModelScope).editPrefs(prefsOut) { - nav.onNavigateBackHome() + nav.onNavigateBackToExitNodes() LoadingIndicator.stop() } } 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 9d40b05..8d70f53 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 @@ -14,6 +14,8 @@ 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.LoadingIndicator import com.tailscale.ipn.ui.util.set import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -183,4 +185,22 @@ open class IpnViewModel : ViewModel() { completionHandler(it) } } + + // Exit Node Manipulation + + fun toggleExitNode() { + val prefs = prefs.value ?: return + + LoadingIndicator.start() + if (prefs.activeExitNodeID != null) { + // We have an active exit node so we should keep it, but disable it + Client(viewModelScope).setUseExitNode(false) { LoadingIndicator.stop() } + } else if (prefs.selectedExitNodeID != null) { + // We have a prior exit node to enable + Client(viewModelScope).setUseExitNode(true) { LoadingIndicator.stop() } + } else { + // This should not be possible. In this state the button is hidden + Log.e(TAG, "No exit node to disable and no prior exit node to enable") + } + } } 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 692038c..ada14a1 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 @@ -3,13 +3,11 @@ package com.tailscale.ipn.ui.viewModel +import android.util.Log import androidx.lifecycle.viewModelScope import com.tailscale.ipn.R -import com.tailscale.ipn.ui.localapi.Client -import com.tailscale.ipn.ui.model.Ipn import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.set @@ -60,18 +58,15 @@ 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 searchPeers(searchTerm: String) { this.searchTerm.set(searchTerm) } - - fun disableExitNode() { - LoadingIndicator.start() - val prefsOut = Ipn.MaskedPrefs() - prefsOut.ExitNodeID = null - Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() } - } } private fun State?.userStringRes(): Int { diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 08c6bfa..faa93e6 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -64,6 +64,8 @@ This device must be approved by an administrator before it can connect to the tailnet. Open admin console Authorization required + Disable + Enable OS @@ -140,7 +142,6 @@ 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 - Stop Tailnet lock diff --git a/go.mod b/go.mod index 15fa3cc..777ae6b 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 golang.org/x/sys v0.18.0 inet.af/netaddr v0.0.0-20220617031823-097006376321 - tailscale.com v1.65.0-pre.0.20240417162431-449be38e0381 + tailscale.com v1.65.0-pre.0.20240418182715-02c6af2a69a8 ) require ( @@ -84,7 +84,7 @@ require ( golang.org/x/crypto v0.21.0 // indirect golang.org/x/exp v0.0.0-20240119083558-1b970713d09a // indirect golang.org/x/mod v0.16.0 // indirect - golang.org/x/net v0.22.0 // indirect + golang.org/x/net v0.23.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index 4503350..7c9c4cb 100644 --- a/go.sum +++ b/go.sum @@ -201,8 +201,8 @@ golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -258,5 +258,5 @@ nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= -tailscale.com v1.65.0-pre.0.20240417162431-449be38e0381 h1:Wiz4SeARcNVLO7rmZy0mPj0RJHcSdHIQ8itrPkgNKyo= -tailscale.com v1.65.0-pre.0.20240417162431-449be38e0381/go.mod h1:PVwaayzADTOctljVKj+M50OtQ0dOGYBLi2fdbOxo6vw= +tailscale.com v1.65.0-pre.0.20240418182715-02c6af2a69a8 h1:8aRe1ZYCD2st8Yu+4l2p6RJsKxVyrh3b+EL1+OmqCHA= +tailscale.com v1.65.0-pre.0.20240418182715-02c6af2a69a8/go.mod h1:Vzqw2fmVnsh7oT1iCqM1ZQlwQru9RjPJmH+srY0Af14=