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 2872660..ff975f3 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 c045fc5..12fdd55 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 @@ -238,6 +238,17 @@ val ColorScheme.secondaryButton: ButtonColors 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 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 0ad1f43..7703388 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 @@ -66,6 +66,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.button import com.tailscale.ipn.ui.theme.disabled import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.minTextSize @@ -168,13 +169,26 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode @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( @@ -185,7 +199,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(), overlineContent = { Text( @@ -207,17 +221,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.button + else MaterialTheme.colorScheme.secondaryButton, + onClick = { viewModel.toggleExitNode() }) { + Text( + if (prefs.activeExitNodeID.isNullOrEmpty()) + stringResource(id = R.string.enable) + else stringResource(id = R.string.disable)) } } }) 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/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 4598774..991793c 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,10 +3,10 @@ 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 @@ -60,17 +60,30 @@ 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() { + fun toggleExitNode() { + val prefs = prefs.value ?: return + LoadingIndicator.start() - val prefsOut = Ipn.MaskedPrefs() - prefsOut.ExitNodeID = null - Client(viewModelScope).editPrefs(prefsOut) { LoadingIndicator.stop() } + 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 an no prior exit node to enable") + } } } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 7816f48..36fde1b 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -129,7 +129,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 @@ -227,5 +226,7 @@ Tailscale is a mesh VPN for securely connecting your devices. 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. Scan this QR code to log in to your tailnet + Disable + Enable diff --git a/go.mod b/go.mod index a774c8a..a85f7b5 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,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.63.0-pre.0.20240404175649-853e3e29a0a6 + tailscale.com v1.63.0-pre.0.20240409210520-8fa302661425 ) require ( @@ -102,3 +102,5 @@ require ( gvisor.dev/gvisor v0.0.0-20240306221502-ee1e1f6070e3 // indirect nhooyr.io/websocket v1.8.10 // indirect ) + +replace tailscale.com => tailscale.com v1.63.0-pre.0.20240411152250-b28c268c3ef5 diff --git a/go.sum b/go.sum index b24582e..f835a71 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 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.20240404175649-853e3e29a0a6/go.mod h1:6kGByHNxnFfK1i4gVpdtvpdS1HicHohWXnsfwmXy64I= +tailscale.com v1.63.0-pre.0.20240411152250-b28c268c3ef5 h1:hLoVSTmyJI7Ra+R4Pdvz6sdoa4ZQvRFMyxydHMqoidw= +tailscale.com v1.63.0-pre.0.20240411152250-b28c268c3ef5/go.mod h1:6kGByHNxnFfK1i4gVpdtvpdS1HicHohWXnsfwmXy64I= diff --git a/go.toolchain.rev b/go.toolchain.rev index 9eb5f82..f446da9 100644 --- a/go.toolchain.rev +++ b/go.toolchain.rev @@ -1 +1 @@ -f86d7c8ef64a0f8a2516fc23652eee28abc8d8e0 +48d71857bf5352daaa10b61dd3e9b1c0dd51e27a