From c3b62124bbda9a8dab255d1695e9ace3f4d49855 Mon Sep 17 00:00:00 2001 From: Andrea Gottardo Date: Fri, 22 Mar 2024 11:27:23 -0700 Subject: [PATCH] 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 --- .../java/com/tailscale/ipn/MainActivity.kt | 8 +- .../java/com/tailscale/ipn/ui/model/Ipn.kt | 2 +- .../tailscale/ipn/ui/view/ExitNodePicker.kt | 27 +++- .../tailscale/ipn/ui/view/RunExitNodeView.kt | 115 ++++++++++++++++++ .../ui/viewModel/ExitNodePickerViewModel.kt | 6 +- .../ipn/ui/viewModel/RunExitNodeViewModel.kt | 97 +++++++++++++++ android/src/main/res/drawable/android.xml | 5 + android/src/main/res/drawable/computer.xml | 5 + android/src/main/res/drawable/globe.xml | 5 + android/src/main/res/values/strings.xml | 12 +- 10 files changed, 276 insertions(+), 6 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt create mode 100644 android/src/main/java/com/tailscale/ipn/ui/viewModel/RunExitNodeViewModel.kt create mode 100644 android/src/main/res/drawable/android.xml create mode 100644 android/src/main/res/drawable/computer.xml create mode 100644 android/src/main/res/drawable/globe.xml diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 342c596..2e0dc6a 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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}", 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 65f1130..7d4bba9 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 @@ -111,7 +111,7 @@ class Ipn { ShieldsUpSet = true } - var AdvertiseRoutes: Boolean? = null + var AdvertiseRoutes: List? = null set(value) { field = value AdvertiseRoutesSet = true diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index d291a18..96f0e48 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -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)) + } + }) + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt new file mode 100644 index 0000000..7356e4c --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/RunExitNodeView.kt @@ -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)) + } +} 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 90c0e79..f97dbba 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 @@ -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> = MutableStateFlow(TreeMap()) val anyActive: StateFlow = MutableStateFlow(false) + val isRunningExitNode: StateFlow = 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 = diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/RunExitNodeViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/RunExitNodeViewModel.kt new file mode 100644 index 0000000..f847b8d --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/RunExitNodeViewModel.kt @@ -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 create(modelClass: Class): 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 = 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().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 + } +} diff --git a/android/src/main/res/drawable/android.xml b/android/src/main/res/drawable/android.xml new file mode 100644 index 0000000..4db0bf4 --- /dev/null +++ b/android/src/main/res/drawable/android.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/drawable/computer.xml b/android/src/main/res/drawable/computer.xml new file mode 100644 index 0000000..43924ec --- /dev/null +++ b/android/src/main/res/drawable/computer.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/drawable/globe.xml b/android/src/main/res/drawable/globe.xml new file mode 100644 index 0000000..1625b40 --- /dev/null +++ b/android/src/main/res/drawable/globe.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index ad5d193..36ec421 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -12,7 +12,7 @@ %s More - offline + Offline OK @@ -90,5 +90,15 @@ Tailnet Exit Nodes Mullvad VPN Best Available + Run as Exit Node + Run this device as an exit node? + 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. + 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. + Stop Running as Exit Node + Start Running as Exit Node + Now Running as Exit Node + 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