Skip to content

Commit

Permalink
android/ui: add support for remembering the last used exit node (#305)
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
barnstar committed Apr 18, 2024
1 parent 75e2d89 commit f4d2a27
Show file tree
Hide file tree
Showing 10 changed files with 112 additions and 36 deletions.
6 changes: 6 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<IpnState.Status>) -> Unit
Expand Down Expand Up @@ -85,6 +86,11 @@ class Client(private val scope: CoroutineScope) {
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) {
get(Endpoint.PROFILES, responseHandler = responseHandler)
}
Expand Down
24 changes: 23 additions & 1 deletion android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/theme/Theme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
52 changes: 36 additions & 16 deletions android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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))
}
}
})
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -518,8 +540,6 @@ fun MainViewPreview() {
MainView(
{},
MainViewNavigation(
onNavigateToSettings = {},
onNavigateToPeerDetails = {},
onNavigateToExitNodes = {}),
onNavigateToSettings = {}, onNavigateToPeerDetails = {}, onNavigateToExitNodes = {}),
vm)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion android/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
<string name="machine_auth_explainer">This device must be approved by an administrator before it can connect to the tailnet.</string>
<string name="open_admin_console">Open admin console</string>
<string name="needs_machine_auth">Authorization required</string>
<string name="disable">Disable</string>
<string name="enable">Enable</string>

<!-- Strings for peer details -->
<string name="os">OS</string>
Expand Down Expand Up @@ -140,7 +142,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="enabled">Enabled</string>
<string name="disabled">Disabled</string>
<string name="stop">Stop</string>

<!-- Strings for the tailnet lock screen -->
<string name="tailnet_lock">Tailnet lock</string>
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=

0 comments on commit f4d2a27

Please sign in to comment.