android/ui: add support for remembering the last used exit node

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 <jonathan@tailscale.com>
Jonathan Nobels 1 month ago
parent a77edc6724
commit 4ef97b3a31

@ -46,6 +46,7 @@ private object Endpoint {
const val FILES = "files" const val FILES = "files"
const val FILE_PUT = "file-put" const val FILE_PUT = "file-put"
const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address" const val TAILFS_SERVER_ADDRESS = "tailfs/fileserver-address"
const val ENABLE_EXIT_NODE = "set-use-exit-node-enabled"
} }
typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit typealias StatusResponseHandler = (Result<IpnState.Status>) -> Unit
@ -85,6 +86,11 @@ class Client(private val scope: CoroutineScope) {
return patch(Endpoint.PREFS, body, responseHandler = responseHandler) return patch(Endpoint.PREFS, body, responseHandler = responseHandler)
} }
fun setUseExitNode(use: Boolean, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val path = "${Endpoint.ENABLE_EXIT_NODE}?enabled=$use"
return post(path, responseHandler = responseHandler)
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) { fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
get(Endpoint.PROFILES, responseHandler = responseHandler) get(Endpoint.PROFILES, responseHandler = responseHandler)
} }

@ -65,7 +65,22 @@ class Ipn {
var ForceDaemon: Boolean = false, var ForceDaemon: Boolean = false,
var HostName: String = "", var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true), 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 @Serializable
data class MaskedPrefs( data class MaskedPrefs(
@ -79,6 +94,7 @@ class Ipn {
var AdvertiseRoutesSet: Boolean? = null, var AdvertiseRoutesSet: Boolean? = null,
var ForceDaemonSet: Boolean? = null, var ForceDaemonSet: Boolean? = null,
var HostnameSet: Boolean? = null, var HostnameSet: Boolean? = null,
var InternalExitNodePriorSet: Boolean? = null,
) { ) {
var ControlURL: String? = null var ControlURL: String? = null
@ -105,6 +121,12 @@ class Ipn {
ExitNodeIDSet = true ExitNodeIDSet = true
} }
var InternalExitNodePrior: String? = null
set(value) {
field = value
InternalExitNodePriorSet = true
}
var ExitNodeAllowLANAccess: Boolean? = null var ExitNodeAllowLANAccess: Boolean? = null
set(value) { set(value) {
field = value field = value

@ -238,6 +238,17 @@ val ColorScheme.secondaryButton: ButtonColors
disabledContentColor = defaults.disabledContentColor) disabledContentColor = defaults.disabledContentColor)
} }
val ColorScheme.button: 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 val ColorScheme.disabled: Color
get() = Color(0xFFAFACAB) // gray-400 get() = Color(0xFFAFACAB) // gray-400

@ -66,6 +66,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.button
import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.disabled
import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize import com.tailscale.ipn.ui.theme.minTextSize
@ -168,13 +169,26 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
@Composable @Composable
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) { fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.prefs.collectAsState() val maybePrefs = viewModel.prefs.collectAsState()
val netmap = viewModel.netmap.collectAsState() val netmap = viewModel.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID
val peer = exitNodeId?.let { id -> netmap.value?.Peers?.find { it.StableID == id } } // There's nothing to render if we haven't loaded the prefs yet
val location = peer?.Hostinfo?.Location val prefs = maybePrefs.value ?: return
val name = peer?.ComputedName
val active = peer != null // 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(modifier = Modifier.background(color = MaterialTheme.colorScheme.surfaceContainer)) {
Box( Box(
@ -185,7 +199,7 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
ListItem( ListItem(
modifier = Modifier.clickable { navAction() }, modifier = Modifier.clickable { navAction() },
colors = colors =
if (active) MaterialTheme.colorScheme.primaryListItem if (activeAndRunning) MaterialTheme.colorScheme.primaryListItem
else ListItemDefaults.colors(), else ListItemDefaults.colors(),
overlineContent = { overlineContent = {
Text( Text(
@ -207,17 +221,24 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
imageVector = Icons.Outlined.ArrowDropDown, imageVector = Icons.Outlined.ArrowDropDown,
contentDescription = null, contentDescription = null,
tint = tint =
if (active) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f) if (activeAndRunning)
MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant, else MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}, },
trailingContent = { trailingContent = {
if (peer != null) { if (exitNodePeer != null) {
Button( Button(
colors = MaterialTheme.colorScheme.secondaryButton, colors =
onClick = { viewModel.disableExitNode() }) { if (prefs.activeExitNodeID.isNullOrEmpty())
Text(stringResource(R.string.stop)) MaterialTheme.colorScheme.button
else MaterialTheme.colorScheme.secondaryButton,
onClick = { viewModel.toggleExitNode() }) {
Text(
if (prefs.activeExitNodeID.isNullOrEmpty())
stringResource(id = R.string.enable)
else stringResource(id = R.string.disable))
} }
} }
}) })

@ -63,7 +63,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
.stateIn(viewModelScope) .stateIn(viewModelScope)
.collect { (netmap, prefs) -> .collect { (netmap, prefs) ->
isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) }) isRunningExitNode.set(prefs?.let { AdvertisedRoutesHelper.exitNodeOnFromPrefs(it) })
val exitNodeId = prefs?.ExitNodeID val exitNodeId = prefs?.activeExitNodeID ?: prefs?.selectedExitNodeID
netmap?.Peers?.let { peers -> netmap?.Peers?.let { peers ->
val allNodes = val allNodes =
peers peers
@ -137,8 +137,9 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
LoadingIndicator.start() LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs() val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeID = node.id prefsOut.ExitNodeID = node.id
Client(viewModelScope).editPrefs(prefsOut) { Client(viewModelScope).editPrefs(prefsOut) {
nav.onNavigateBackHome() nav.onNavigateBackToExitNodes()
LoadingIndicator.stop() LoadingIndicator.stop()
} }
} }

@ -3,10 +3,10 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.localapi.Client 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.model.Ipn.State
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
@ -60,17 +60,30 @@ class MainViewModel : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
searchTerm.collect { term -> peers.set(peerCategorizer.groupedAndFilteredPeers(term)) } 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) { fun searchPeers(searchTerm: String) {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
} }
fun disableExitNode() { fun toggleExitNode() {
val prefs = prefs.value ?: return
LoadingIndicator.start() LoadingIndicator.start()
val prefsOut = Ipn.MaskedPrefs() if (prefs.activeExitNodeID != null) {
prefsOut.ExitNodeID = null // We have an active exit node so we should keep it, but disable it
Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() } 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 an no prior exit node to enable")
}
} }
} }

@ -129,7 +129,6 @@
<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="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="enabled">Enabled</string>
<string name="disabled">Disabled</string> <string name="disabled">Disabled</string>
<string name="stop">Stop</string>
<!-- Strings for the tailnet lock screen --> <!-- Strings for the tailnet lock screen -->
<string name="tailnet_lock">Tailnet lock</string> <string name="tailnet_lock">Tailnet lock</string>
@ -227,5 +226,7 @@
<string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices.</string> <string name="welcome1">Tailscale is a mesh VPN for securely connecting your devices.</string>
<string name="welcome2">All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.</string> <string name="welcome2">All connections are device-to-device, so we never see your data. We collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.</string>
<string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string> <string name="scan_to_connect_to_your_tailnet">Scan this QR code to log in to your tailnet</string>
<string name="disable">Disable</string>
<string name="enable">Enable</string>
</resources> </resources>

@ -13,7 +13,7 @@ require (
golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87 golang.org/x/mobile v0.0.0-20240319015410-c58ccf4b0c87
golang.org/x/sys v0.18.0 golang.org/x/sys v0.18.0
inet.af/netaddr v0.0.0-20220617031823-097006376321 inet.af/netaddr v0.0.0-20220617031823-097006376321
tailscale.com v1.63.0-pre.0.20240404175649-853e3e29a0a6 tailscale.com v1.63.0-pre.0.20240409210520-8fa302661425
) )
require ( require (
@ -102,3 +102,5 @@ require (
gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 // indirect gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 // indirect
nhooyr.io/websocket v1.8.10 // indirect nhooyr.io/websocket v1.8.10 // indirect
) )
replace tailscale.com => tailscale.com v1.63.0-pre.0.20240411152250-b28c268c3ef5

@ -670,5 +670,5 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= 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= software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= sourcegraph.com/sourcegraph/appdash v0.0.0-20190731080439-ebfcffb1b5c0/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU=
tailscale.com v1.63.0-pre.0.20240404175649-853e3e29a0a6 h1:olrk8pALH8inlmr4+euszd6cDI1dHhkRiFffxQ+fvpU= tailscale.com v1.63.0-pre.0.20240411152250-b28c268c3ef5 h1:hLoVSTmyJI7Ra+R4Pdvz6sdoa4ZQvRFMyxydHMqoidw=
tailscale.com v1.63.0-pre.0.20240404175649-853e3e29a0a6/go.mod h1:6kGByHNxnFfK1i4gVpdtvpdS1HicHohWXnsfwmXy64I= tailscale.com v1.63.0-pre.0.20240411152250-b28c268c3ef5/go.mod h1:6kGByHNxnFfK1i4gVpdtvpdS1HicHohWXnsfwmXy64I=

@ -1 +1 @@
f86d7c8ef64a0f8a2516fc23652eee28abc8d8e0 48d71857bf5352daaa10b61dd3e9b1c0dd51e27a

Loading…
Cancel
Save