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
parent
910511d838
commit
c3b62124bb
@ -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))
|
||||
}
|
||||
}
|
@ -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>
|
Loading…
Reference in New Issue