android: add UI to run as exit node (#230)

Updates ENG-2913

This PR provides UI to let the user toggle AdvertisedRoutes by adding/removing the zero routes, with a view to warn the user about battery life impact and potential cellular data charges. Language and graphics to mimic what we currently show on Apple TV, final designs will follow as per @sonovawolf.

Signed-off-by: Andrea Gottardo <andrea@gottardo.me>
pull/235/head
Andrea Gottardo 8 months ago committed by GitHub
parent 910511d838
commit c3b62124bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -37,6 +37,7 @@ import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.Settings
import com.tailscale.ipn.ui.view.UserSwitcherView
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
@ -91,7 +92,9 @@ class MainActivity : ComponentActivity() {
onNavigateHome = {
navController.popBackStack(route = "main", inclusive = false)
},
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") })
onNavigateToExitNodePicker = { navController.popBackStack() },
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })
composable("main") { MainView(navigation = mainViewNav) }
composable("settings") { Settings(settingsNav) }
@ -103,6 +106,9 @@ class MainActivity : ComponentActivity() {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
}
composable("runExitNode") {
RunExitNodeView(exitNodePickerNav)
}
}
composable(
"peerDetails/{nodeId}",

@ -111,7 +111,7 @@ class Ipn {
ShieldsUpSet = true
}
var AdvertiseRoutes: Boolean? = null
var AdvertiseRoutes: List<String>? = null
set(value) {
field = value
AdvertiseRoutesSet = true

@ -15,6 +15,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
@ -25,7 +26,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
@ -50,6 +50,11 @@ fun ExitNodePicker(
val anyActive = model.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "runExitNode") {
RunAsExitNodeItem(nav = nav, viewModel = model)
HorizontalDivider()
}
item(key = "none") {
ExitNodeItem(
model,
@ -132,9 +137,27 @@ fun ExitNodeItem(
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.more))
} else if (!node.online) {
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.offline), fontStyle = FontStyle.Italic)
Text(stringResource(R.string.offline))
}
}
})
}
}
@Composable
fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) {
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
Box {
ListItem(
modifier = Modifier.clickable { nav.onNavigateToRunAsExitNode() },
headlineContent = { Text(stringResource(id = R.string.run_as_exit_node)) },
trailingContent = {
if (isRunningExitNode) {
Text(stringResource(R.string.enabled))
} else {
Text(stringResource(R.string.disabled))
}
})
}
}

@ -0,0 +1,115 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.ts_color_light_blue
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
@Composable
fun RunExitNodeView(
nav: ExitNodePickerNav,
model: RunExitNodeViewModel = viewModel(factory = RunExitNodeViewModelFactory())
) {
val isRunningExitNode = model.isRunningExitNode.collectAsState().value
Scaffold(
topBar = { Header(R.string.run_as_exit_node, onBack = nav.onNavigateToExitNodePicker) }) {
innerPadding ->
LoadingIndicator.Wrap {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement =
Arrangement.spacedBy(16.dp, alignment = Alignment.Top),
modifier = Modifier.padding(innerPadding).padding(16.dp).fillMaxHeight()) {
RunExitNodeGraphic()
if (isRunningExitNode) {
Text(
stringResource(R.string.running_as_exit_node),
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.run_exit_node_explainer_running))
} else {
Text(
stringResource(R.string.run_this_device_as_an_exit_node),
fontFamily = MaterialTheme.typography.titleLarge.fontFamily,
fontSize = MaterialTheme.typography.titleLarge.fontSize,
fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.run_exit_node_explainer))
}
Text(stringResource(R.string.run_exit_node_caution), color = Color.Red)
PrimaryActionButton(onClick = { model.setRunningExitNode(!isRunningExitNode) }) {
if (isRunningExitNode) {
Text(stringResource(R.string.stop_running_as_exit_node))
} else {
Text(stringResource(R.string.start_running_as_exit_node))
}
}
}
}
}
}
@Composable
fun RunExitNodeGraphic() {
@Composable
fun ArrowForward() {
Icon(
Icons.AutoMirrored.Outlined.ArrowForward,
"Arrow Forward",
tint = MaterialTheme.colorScheme.secondary,
modifier = Modifier.size(24.dp))
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 18.dp)) {
Icon(
painter = painterResource(id = R.drawable.computer),
"Computer icon",
tint = ts_color_light_blue,
modifier = Modifier.size(36.dp))
ArrowForward()
Icon(
painter = painterResource(id = R.drawable.android),
"Android icon",
tint = ts_color_light_blue,
modifier = Modifier.size(36.dp))
ArrowForward()
Icon(
painter = painterResource(id = R.drawable.globe),
"Globe icon",
tint = ts_color_light_blue,
modifier = Modifier.size(36.dp))
}
}

@ -12,16 +12,18 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.set
import java.util.TreeMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.TreeMap
data class ExitNodePickerNav(
val onNavigateHome: () -> Unit,
val onNavigateToExitNodePicker: () -> Unit,
val onNavigateToMullvadCountry: (String) -> Unit,
val onNavigateToRunAsExitNode: () -> Unit,
)
class ExitNodePickerViewModelFactory(private val nav: ExitNodePickerNav) :
@ -49,6 +51,7 @@ class ExitNodePickerViewModel(private val nav: ExitNodePickerNav) : IpnViewModel
MutableStateFlow(TreeMap())
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(TreeMap())
val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
val isRunningExitNode: StateFlow<Boolean> = MutableStateFlow(false)
init {
viewModelScope.launch {
@ -56,6 +59,7 @@ 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?.ExitNodeID
netmap?.Peers?.let { peers ->
val allNodes =

@ -0,0 +1,97 @@
// 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 <T : ViewModel> create(modelClass: Class<T>): 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<Boolean> = 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<String>().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
}
}

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17.6,9.48l1.84,-3.18c0.16,-0.31 0.04,-0.69 -0.26,-0.85c-0.29,-0.15 -0.65,-0.06 -0.83,0.22l-1.88,3.24c-2.86,-1.21 -6.08,-1.21 -8.94,0L5.65,5.67c-0.19,-0.29 -0.58,-0.38 -0.87,-0.2C4.5,5.65 4.41,6.01 4.56,6.3L6.4,9.48C3.3,11.25 1.28,14.44 1,18h22C22.72,14.44 20.7,11.25 17.6,9.48zM7,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25S8.25,13.31 8.25,14C8.25,14.69 7.69,15.25 7,15.25zM17,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25s1.25,0.56 1.25,1.25C18.25,14.69 17.69,15.25 17,15.25z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="960" android:viewportWidth="960" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M40,840L40,760L920,760L920,840L40,840ZM160,720Q127,720 103.5,696.5Q80,673 80,640L80,200Q80,167 103.5,143.5Q127,120 160,120L800,120Q833,120 856.5,143.5Q880,167 880,200L880,640Q880,673 856.5,696.5Q833,720 800,720L160,720ZM160,640L800,640Q800,640 800,640Q800,640 800,640L800,200Q800,200 800,200Q800,200 800,200L160,200Q160,200 160,200Q160,200 160,200L160,640Q160,640 160,640Q160,640 160,640ZM160,640Q160,640 160,640Q160,640 160,640L160,200Q160,200 160,200Q160,200 160,200L160,200Q160,200 160,200Q160,200 160,200L160,640Q160,640 160,640Q160,640 160,640L160,640Z"/>
</vector>

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM4,12c0,-0.61 0.08,-1.21 0.21,-1.78L8.99,15v1c0,1.1 0.9,2 2,2v1.93C7.06,19.43 4,16.07 4,12zM17.89,17.4c-0.26,-0.81 -1,-1.4 -1.9,-1.4h-1v-3c0,-0.55 -0.45,-1 -1,-1h-6v-2h2c0.55,0 1,-0.45 1,-1L10.99,7h2c1.1,0 2,-0.9 2,-2v-0.41C17.92,5.77 20,8.65 20,12c0,2.08 -0.81,3.98 -2.11,5.4z"/>
</vector>

@ -12,7 +12,7 @@
<string name="empty"> </string>
<string name="template">%s</string>
<string name="more">More</string>
<string name="offline">offline</string>
<string name="offline">Offline</string>
<string name="ok">OK</string>
<!-- Strings for the about screen -->
@ -90,5 +90,15 @@
<string name="tailnet_exit_nodes">Tailnet Exit Nodes</string>
<string name="mullvad_exit_nodes">Mullvad VPN</string>
<string name="best_available">Best Available</string>
<string name="run_as_exit_node">Run as Exit Node</string>
<string name="run_this_device_as_an_exit_node">Run this device as an exit node?</string>
<string name="run_exit_node_explainer">Other devices in your tailnet will be able to 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_caution">Caution: Running an exit node will severely impact battery life. On a metered data plan, significant cellular data charges may also apply. Always disable this feature when no longer needed.</string>
<string name="stop_running_as_exit_node">Stop Running as Exit Node</string>
<string name="start_running_as_exit_node">Start Running as Exit Node</string>
<string name="running_as_exit_node">Now Running as Exit Node</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="disabled">Disabled</string>
</resources>

Loading…
Cancel
Save