android: implement exit node picker

Updates tailscale/corp#18202

Signed-off-by: Percy Wegmann <percy@tailscale.com>
pull/212/head
Percy Wegmann 2 months ago committed by Percy Wegmann
parent 06e850bbd5
commit 9a6aecb454

@ -10,12 +10,14 @@ import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.remember
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import androidx.navigation.navigation
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.ui.service.IpnManager
import com.tailscale.ipn.ui.theme.AppTheme
@ -26,6 +28,7 @@ import com.tailscale.ipn.ui.view.MDMSettingsDebugView
import com.tailscale.ipn.ui.view.MainView
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.Settings
import com.tailscale.ipn.ui.view.SettingsNav
@ -48,43 +51,62 @@ class MainActivity : ComponentActivity() {
AppTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "main") {
val mainViewNav = MainViewNavigation(
onNavigateToSettings = { navController.navigate("settings") },
val mainViewNav =
MainViewNavigation(onNavigateToSettings = { navController.navigate("settings") },
onNavigateToPeerDetails = {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") }
)
onNavigateToExitNodes = { navController.navigate("exitNodes") })
val settingsNav = SettingsNav(
onNavigateToBugReport = { navController.navigate("bugReport") },
val settingsNav =
SettingsNav(onNavigateToBugReport = { navController.navigate("bugReport") },
onNavigateToAbout = { navController.navigate("about") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }
)
onNavigateToManagedBy = { navController.navigate("managedBy") })
composable("main") {
MainView(
viewModel = MainViewModel(manager.model, manager),
navigation = mainViewNav
viewModel = MainViewModel(manager.model, manager),
navigation = mainViewNav
)
}
composable("settings") {
Settings(SettingsViewModel(manager, settingsNav))
}
composable("exitNodes") {
ExitNodePicker(ExitNodePickerViewModel(manager.model))
navigation(startDestination = "list", route = "exitNodes") {
composable("list") {
val viewModel = remember {
ExitNodePickerViewModel(manager.model) {
navController.navigate("main")
}
}
ExitNodePicker(viewModel) {
navController.navigate("mullvad/$it")
}
}
composable(
"mullvad/{countryCode}", arguments = listOf(navArgument("countryCode") {
type = NavType.StringType
})
) {
val viewModel = remember {
ExitNodePickerViewModel(manager.model) {
navController.navigate("main")
}
}
MullvadExitNodePicker(
viewModel, it.arguments!!.getString("countryCode")!!
)
}
}
composable(
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) {
PeerDetails(
PeerDetailsViewModel(
manager.model, nodeId = it.arguments?.getString("nodeId")
?: ""
)
PeerDetailsViewModel(
manager.model, nodeId = it.arguments?.getString("nodeId") ?: ""
)
)
}
composable("bugReport") {
@ -129,7 +151,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
val restrictionsManager =
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
this.getSystemService(Context.RESTRICTIONS_SERVICE) as RestrictionsManager
manager.mdmSettings = MDMSettings(restrictionsManager)
}
}

@ -10,6 +10,7 @@ import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.IpnState
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
@ -45,10 +46,12 @@ class LocalApiClient(private val scope: CoroutineScope) {
// body: The body of the request.
// cookie: A unique identifier for this request. This is used map responses to
// the corresponding request. Cookies must be unique for each request.
private external fun doRequest(request: String, method: String, body: ByteArray?, cookie: String)
private external fun doRequest(
request: String, method: String, body: ByteArray?, cookie: String
)
fun <T> executeRequest(request: LocalAPIRequest<T>) {
scope.launch {
private fun <T> executeRequest(request: LocalAPIRequest<T>) {
scope.launch(Dispatchers.IO) {
isReady.await()
Log.d("LocalApiClient", "Executing request:${request.method}:${request.path}")
requests[request.cookie] = request
@ -63,7 +66,9 @@ class LocalApiClient(private val scope: CoroutineScope) {
requests.remove(cookie)?.let { request ->
Log.d("LocalApiClient", "Response for request:${request.path} cookie:${request.cookie}")
// The response handler will invoked internally by the request parser
request.parser(response)
scope.launch {
request.parser(response)
}
} ?: { Log.e("LocalApiClient", "Received response for unknown request: $cookie") }
}
@ -90,7 +95,7 @@ class LocalApiClient(private val scope: CoroutineScope) {
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit) {
val req = LocalAPIRequest.editPrefs(prefs, responseHandler)
executeRequest<Ipn.Prefs>(req)
executeRequest(req)
}
fun getProfiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit) {
@ -106,7 +111,7 @@ class LocalApiClient(private val scope: CoroutineScope) {
fun startLoginInteractive() {
val req = LocalAPIRequest.startLoginInteractive { result ->
result.success?.let { Log.d("LocalApiClient", "Login started: $it") }
?: run { Log.e("LocalApiClient", "Error starting login: ${result.error}") }
?: run { Log.e("LocalApiClient", "Error starting login: ${result.error}") }
}
executeRequest<String>(req)
}
@ -114,7 +119,7 @@ class LocalApiClient(private val scope: CoroutineScope) {
fun logout() {
val req = LocalAPIRequest.logout { result ->
result.success?.let { Log.d("LocalApiClient", "Logout started: $it") }
?: run { Log.e("LocalApiClient", "Error starting logout: ${result.error}") }
?: run { Log.e("LocalApiClient", "Error starting logout: ${result.error}") }
}
executeRequest<String>(req)
}

@ -49,9 +49,7 @@ private object Endpoint {
// it up if possible.
enum class APIErrorVals(val rawValue: String) {
UNPARSEABLE_RESPONSE("Unparseable localAPI response"),
NOT_READY("Not Ready"),
NO_PREFS("Current prefs not available");
UNPARSEABLE_RESPONSE("Unparseable localAPI response"), NOT_READY("Not Ready"), NO_PREFS("Current prefs not available");
fun toError(): Error {
return Error(rawValue)
@ -59,10 +57,10 @@ enum class APIErrorVals(val rawValue: String) {
}
class LocalAPIRequest<T>(
path: String,
val method: String,
val body: ByteArray? = null,
val parser: (ByteArray) -> Unit,
path: String,
val method: String,
val body: ByteArray? = null,
val parser: (ByteArray) -> Unit,
) {
val path = "/localapi/v0/$path"
val cookie = UUID.randomUUID().toString()
@ -72,36 +70,24 @@ class LocalAPIRequest<T>(
val decoder = Json { ignoreUnknownKeys = true }
fun <T> get(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "GET",
path = path,
body = body,
parser = parser
)
LocalAPIRequest<T>(
method = "GET", path = path, body = body, parser = parser
)
fun <T> put(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "PUT",
path = path,
body = body,
parser = parser
)
LocalAPIRequest<T>(
method = "PUT", path = path, body = body, parser = parser
)
fun <T> post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "POST",
path = path,
body = body,
parser = parser
)
LocalAPIRequest<T>(
method = "POST", path = path, body = body, parser = parser
)
fun <T> patch(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) =
LocalAPIRequest<T>(
method = "PATCH",
path = path,
body = body,
parser = parser
)
LocalAPIRequest<T>(
method = "PATCH", path = path, body = body, parser = parser
)
fun status(responseHandler: StatusResponseHandler): LocalAPIRequest<IpnState.Status> {
return get(Endpoint.STATUS) { resp ->
@ -121,14 +107,15 @@ class LocalAPIRequest<T>(
}
}
fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit): LocalAPIRequest<Ipn.Prefs> {
fun editPrefs(
prefs: Ipn.MaskedPrefs, responseHandler: (Result<Ipn.Prefs>) -> Unit
): LocalAPIRequest<Ipn.Prefs> {
val body = Json.encodeToString(prefs).toByteArray()
return patch(Endpoint.PREFS, body) { resp ->
responseHandler(decode<Ipn.Prefs>(resp))
}
}
fun profiles(responseHandler: (Result<List<IpnLocal.LoginProfile>>) -> Unit): LocalAPIRequest<List<IpnLocal.LoginProfile>> {
return get(Endpoint.PROFILES) { resp ->
responseHandler(decode<List<IpnLocal.LoginProfile>>(resp))

@ -46,21 +46,21 @@ class Ipn {
@Serializable
data class Prefs(
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeId: StableNodeID? = null,
var ExitNodeAllowLanAccess: Boolean = false,
var Config: Persist.Persist? = null,
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
var ControlURL: String = "",
var RouteAll: Boolean = false,
var AllowsSingleHosts: Boolean = false,
var CorpDNS: Boolean = false,
var WantRunning: Boolean = false,
var LoggedOut: Boolean = false,
var ShieldsUp: Boolean = false,
var AdvertiseRoutes: List<String>? = null,
var AdvertiseTags: List<String>? = null,
var ExitNodeID: StableNodeID? = null,
var ExitNodeAllowLANAccess: Boolean = false,
var Config: Persist.Persist? = null,
var ForceDaemon: Boolean = false,
var HostName: String = "",
var AutoUpdate: AutoUpdatePrefs? = AutoUpdatePrefs(true, true),
)
@Serializable
@ -85,12 +85,12 @@ class Ipn {
field = value
CorpDNSSet = true
}
var ExitNodeId: StableNodeID? = null
var ExitNodeID: StableNodeID? = null
set(value) {
field = value
ExitNodeIDSet = true
}
var ExitNodeAllowLanAccess: Boolean? = null
var ExitNodeAllowLANAccess: Boolean? = null
set(value) {
field = value
ExitNodeAllowLANAccessSet = true

@ -26,6 +26,7 @@ class IpnState {
val Online: Boolean,
val ExitNode: Boolean,
val ExitNodeOption: Boolean,
val Active: Boolean,
val PeerAPIURL: List<String>? = null,
val Capabilities: List<String>? = null,
val SSH_HostKeys: List<String>? = null,

@ -49,6 +49,7 @@ class Tailcfg {
var Machine: String? = null,
var RoutableIPs: List<Prefix>? = null,
var Services: List<Service>? = null,
var Location: Location? = null,
)
@Serializable

@ -8,7 +8,6 @@ import com.tailscale.ipn.ui.model.Ipn.Notify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
@ -16,9 +15,7 @@ typealias NotifierCallback = (Notify) -> Unit
class Watcher(
val sessionId: String,
val mask: Int,
val callback: NotifierCallback
val sessionId: String, val mask: Int, val callback: NotifierCallback
)
// Notifier is a wrapper around the IPN Bus notifier. It provides a way to watch
@ -29,20 +26,13 @@ class Watcher(
// The primary entry point here is watchIPNBus which will start a watcher on the IPN bus
// and return you the session Id. When you are done with your watcher, you must call
// unwatchIPNBus with the sessionId.
class Notifier() {
// (jonathan) TODO: We should be using a lifecycle aware scope here
private val scope = CoroutineScope(Dispatchers.IO + Job())
class Notifier(private val scope: CoroutineScope) {
// NotifyWatchOpt is a bitmask of options supplied to the notifier to specify which
// what we want to see on the Noitfy bus
enum class NotifyWatchOpt(val value: Int) {
engineUpdates(1),
initialState(2),
prefs(4),
netmap(8),
noPrivateKey(16),
initialTailFSShares(32)
engineUpdates(1), initialState(2), prefs(4), netmap(8), noPrivateKey(16), initialTailFSShares(
32
)
}
companion object {
@ -80,8 +70,12 @@ class Notifier() {
fun onNotify(notification: String, sessionId: String) {
val notify = decoder.decodeFromString<Notify>(notification)
val watcher = watchers[sessionId]
watcher?.let { watcher.callback(notify) }
?: { Log.e("Notifier", "Received notification for unknown session: ${sessionId}") }
watcher?.let { watcher.callback(notify) } ?: {
Log.e(
"Notifier",
"Received notification for unknown session: ${sessionId}"
)
}
}
// Watch the IPN bus for notifications
@ -91,7 +85,7 @@ class Notifier() {
val sessionId = generateSessionId()
val watcher = Watcher(sessionId, mask, callback)
watchers[sessionId] = watcher
scope.launch {
scope.launch(Dispatchers.IO) {
// Wait for the notifier to be ready
isReady.await()
Log.d("Notifier", "Starting IPN Bus watcher for sessionid: ${sessionId}")
@ -136,10 +130,8 @@ class Notifier() {
fun watchAll(callback: NotifierCallback): String {
return watchIPNBus(
NotifyWatchOpt.netmap.value or
NotifyWatchOpt.prefs.value or
NotifyWatchOpt.initialState.value,
callback
NotifyWatchOpt.netmap.value or NotifyWatchOpt.prefs.value or NotifyWatchOpt.initialState.value,
callback
)
}

@ -26,8 +26,7 @@ interface IpnActions {
}
class IpnManager(scope: CoroutineScope) : IpnActions {
private var notifier = Notifier()
private var notifier = Notifier(scope)
var apiClient = LocalApiClient(scope)
var mdmSettings = MDMSettings()
val model = IpnModel(notifier, apiClient, scope)

@ -9,22 +9,14 @@ import com.tailscale.ipn.ui.model.Ipn
import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Provides a way to expose a MutableStateFlow as an immutable StateFlow.
*/
fun <T> StateFlow<T>.set(v: T) {
(this as MutableStateFlow<T>).value = v
}
class IpnModel(
notifier: Notifier,
val apiClient: LocalApiClient,
val scope: CoroutineScope
notifier: Notifier, val apiClient: LocalApiClient, val scope: CoroutineScope
) {
private var notifierSessions: MutableList<String> = mutableListOf()
@ -50,13 +42,19 @@ class IpnModel(
LocalApiClient.isReady.await()
apiClient.getProfiles { result ->
result.success?.let(loginProfiles::set)
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") }
result.success?.let(loginProfiles::set) ?: run {
Log.e(
"IpnManager", "Error loading profiles: ${result.error}"
)
}
}
apiClient.getCurrentProfile { result ->
result.success?.let(loggedInUser::set)
?: run { Log.e("IpnManager", "Error loading current profile: ${result.error}") }
result.success?.let(loggedInUser::set) ?: run {
Log.e(
"IpnManager", "Error loading current profile: ${result.error}"
)
}
}
}

@ -53,9 +53,9 @@ fun IpnModel.toggleShieldsUp(callback: (Result<Ipn.Prefs>) -> Unit) {
apiClient.editPrefs(prefsOut, callback)
}
fun IpnModel.setExitNodeId(id: String, callback: (Result<Ipn.Prefs>) -> Unit) {
fun IpnModel.setExitNodeId(id: String?, callback: (Result<Ipn.Prefs>) -> Unit) {
val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeId = id
prefsOut.ExitNodeID = id
apiClient.editPrefs(prefsOut, callback)
}

@ -0,0 +1,34 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
/**
* Code adapted from https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75
*/
//Copyright 2023 piashcse (Mehedi Hassan Piash)
//
//Licensed under the Apache License, Version 2.0 (the "License");
//you may not use this file except in compliance with the License.
//You may obtain a copy of the License at
//
//http://www.apache.org/licenses/LICENSE-2.0
//
//Unless required by applicable law or agreed to in writing, software
//distributed under the License is distributed on an "AS IS" BASIS,
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//See the License for the specific language governing permissions and
//limitations under the License.
/**
* Flag turns an ISO3166 country code into a flag emoji.
*/
fun String.flag(): String {
val caps = this.uppercase()
val flagOffset = 0x1F1E6
val asciiOffset = 0x41
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
}

@ -0,0 +1,54 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.CircularProgressIndicator
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 kotlinx.coroutines.flow.MutableStateFlow
object LoadingIndicator {
private val loading = MutableStateFlow(false)
fun start() {
loading.value = true
}
fun stop() {
loading.value = false
}
@Composable
fun Wrap(content: @Composable () -> Unit) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
content()
val isLoading = loading.collectAsState()
if (isLoading.value) {
Box(
Modifier
.matchParentSize()
.background(Color.Gray.copy(alpha = 0.5f))
)
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
}
}
}
}
}

@ -0,0 +1,14 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Provides a way to expose a MutableStateFlow as an immutable StateFlow.
*/
fun <T> StateFlow<T>.set(v: T) {
(this as MutableStateFlow<T>).value = v
}

@ -4,15 +4,151 @@
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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 com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExitNodePicker(viewModel: ExitNodePickerViewModel) {
Column {
Text(text = "Future Home of Picking Exit Nodes")
fun ExitNodePicker(
viewModel: ExitNodePickerViewModel,
onNavigateToMullvadCountry: (String) -> Unit,
) {
LoadingIndicator.Wrap {
Scaffold(topBar = {
TopAppBar(title = { Text(stringResource(R.string.choose_exit_node)) })
}) { innerPadding ->
val tailnetExitNodes = viewModel.tailnetExitNodes.collectAsState()
val mullvadExitNodes = viewModel.mullvadExitNodesByCountryCode.collectAsState()
val anyActive = viewModel.anyActive.collectAsState()
LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "none") {
ExitNodeItem(
viewModel,
ExitNodePickerViewModel.ExitNode(
label = stringResource(R.string.none),
online = true,
selected = !anyActive.value,
),
)
}
item {
ListHeading(stringResource(R.string.tailnet_exit_nodes))
}
items(tailnetExitNodes.value, key = { it.id!! }) { node ->
ExitNodeItem(viewModel, node, indent = 16.dp)
}
item {
ListHeading(stringResource(R.string.mullvad_exit_nodes))
}
val sortedCountries = mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
items(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.
Box {
ListItem(modifier = Modifier
.padding(start = 16.dp)
.clickable {
if (nodes.size > 1) {
onNavigateToMullvadCountry(
countryCode
)
} else {
viewModel.setExitNode(first)
}
}, headlineContent = {
Text("${countryCode.flag()} ${first.country}")
}, trailingContent = {
val text = if (nodes.size == 1) first.city else "${nodes.size}"
val icon =
if (nodes.size > 1) Icons.AutoMirrored.Outlined.KeyboardArrowRight
else if (first.selected) Icons.Outlined.Check
else null
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
Spacer(modifier = Modifier.width(8.dp))
icon?.let {
Icon(
it, contentDescription = stringResource(R.string.more)
)
}
}
})
}
}
}
}
}
}
@Composable
fun ListHeading(label: String, indent: Dp = 0.dp) {
ListItem(modifier = Modifier.padding(start = indent), headlineContent = {
Text(text = label, style = MaterialTheme.typography.titleMedium)
})
}
@Composable
fun ExitNodeItem(
viewModel: ExitNodePickerViewModel, node: ExitNodePickerViewModel.ExitNode, indent: Dp = 0.dp
) {
Box {
ListItem(modifier = Modifier
.padding(start = indent)
.clickable { viewModel.setExitNode(node) },
headlineContent = {
Text(node.city.ifEmpty { node.label })
},
trailingContent = {
Row {
if (node.selected) {
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)
}
}
})
}
}

@ -53,39 +53,40 @@ import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.PrimaryActionButton
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView
data class MainViewNavigation(
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit)
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit)
@Composable
fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center
) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
.fillMaxWidth()
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp))
StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier
.weight(1f)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
.weight(1f)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
Avatar(profile = user.value, size = 36)
}
}
@ -93,22 +94,22 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
when (state.value) {
Ipn.State.Running -> {
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, stringResource(id = R.string.none))
ExitNodeStatus(navigation.onNavigateToExitNodes, viewModel)
PeerList(
searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = viewModel.selfPeerId,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = viewModel.selfPeerId,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
}
Ipn.State.Starting -> StartingView()
else ->
ConnectView(
user.value,
{ viewModel.toggleVpn() },
{ viewModel.login() }
user.value,
{ viewModel.toggleVpn() },
{ viewModel.login() }
)
}
@ -117,20 +118,30 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
}
@Composable
fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = stringResource(id = R.string.none)) {
fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
val prefs = viewModel.model.prefs.collectAsState()
val netmap = viewModel.model.netmap.collectAsState()
val exitNodeId = prefs.value?.ExitNodeID
val exitNode = exitNodeId?.let { id ->
netmap.value?.Peers?.find { it.StableID == id }?.let { peer ->
peer.Hostinfo.Location?.let { location ->
"${location.Country?.flag()} ${location.Country} - ${location.City}"
} ?: peer.Name
}
}
Box(modifier = Modifier
.clickable { navAction() }
.padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
.clickable { navAction() }
.padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) {
Text(text = stringResource(id = R.string.exit_node), style = MaterialTheme.typography.titleMedium)
Row {
Text(text = exitNode, style = MaterialTheme.typography.bodyMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = exitNode ?: stringResource(id = R.string.none), style = MaterialTheme.typography.bodyMedium)
Icon(
Icons.Outlined.ArrowDropDown,
null,
Icons.Outlined.ArrowDropDown,
null,
)
}
}
@ -152,12 +163,12 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton(
modifier = Modifier.size(24.dp),
onClick = { action() }
modifier = Modifier.size(24.dp),
onClick = { action() }
) {
Icon(
Icons.Outlined.Settings,
null,
Icons.Outlined.Settings,
null,
)
}
}
@ -166,16 +177,16 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
fun StartingView() {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
@ -280,54 +291,54 @@ fun PeerList(searchTerm: StateFlow<String>,
) {
LazyColumn(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer),
) {
peerList.value.forEach { peerSet ->
item {
ListItem(headlineContent = {
Text(text = peerSet.user?.DisplayName
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
?: stringResource(id = R.string.unknown_user), style = MaterialTheme.typography.titleLarge)
})
}
peerSet.peers.forEach { peer ->
item {
ListItem(
modifier = Modifier.clickable {
onNavigateToPeerDetails(peer)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected.
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green
} else {
Color.Gray
}
Box(modifier = Modifier
.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
modifier = Modifier.clickable {
onNavigateToPeerDetails(peer)
},
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected.
val isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green
} else {
Color.Gray
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first()
?: "",
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
Box(modifier = Modifier
.size(8.dp)
.background(color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
}
},
supportingContent = {
Text(
text = peer.Addresses?.first()?.split("/")?.first()
?: "",
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
}
)
}
}
}
}
}
}
}

@ -0,0 +1,59 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MullvadExitNodePicker(viewModel: ExitNodePickerViewModel, countryCode: String) {
val mullvadExitNodes = viewModel.mullvadExitNodesByCountryCode.collectAsState()
val bestAvailableByCountry = viewModel.mullvadBestAvailableByCountry.collectAsState()
mullvadExitNodes.value[countryCode]?.toList()?.let { nodes ->
val any = nodes.first()
LoadingIndicator.Wrap {
Scaffold(topBar = {
TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") })
}) { innerPadding ->
LazyColumn(modifier = Modifier.padding(innerPadding)) {
if (nodes.size > 1) {
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
item {
ExitNodeItem(
viewModel, ExitNodePickerViewModel.ExitNode(
id = bestAvailableNode.id,
label = stringResource(R.string.best_available),
online = bestAvailableNode.online,
selected = false,
)
)
}
}
items(nodes) { node ->
ExitNodeItem(viewModel, node)
}
}
}
}
}
}

@ -6,15 +6,14 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.LocalApiClient
import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.service.set
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() {
var bugReportID: StateFlow<BugReportID> = MutableStateFlow("")
val bugReportID: StateFlow<String> = MutableStateFlow("")
init {
viewModelScope.launch {

@ -4,8 +4,118 @@
package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.setExitNodeId
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.launch
import java.util.TreeMap
class ExitNodePickerViewModel(val model: IpnModel) : ViewModel() {
class ExitNodePickerViewModel(private val model: IpnModel, private val onNavigateHome: () -> Unit) :
ViewModel() {
companion object {
const val TAG = "ExitNodePickerViewModel"
}
data class ExitNode(
val id: StableNodeID? = null,
val label: String,
val online: Boolean,
val selected: Boolean,
val mullvad: Boolean = false,
val priority: Int = 0,
val countryCode: String = "",
val country: String = "",
val city: String = ""
)
val tailnetExitNodes: StateFlow<List<ExitNode>> = MutableStateFlow(emptyList())
val mullvadExitNodesByCountryCode: StateFlow<Map<String, List<ExitNode>>> = MutableStateFlow(
TreeMap()
)
val mullvadBestAvailableByCountry: StateFlow<Map<String, ExitNode>> = MutableStateFlow(
TreeMap()
)
val anyActive: StateFlow<Boolean> = MutableStateFlow(false)
init {
viewModelScope.launch {
model.apiClient.getStatus { status ->
when (status.successful) {
false -> Log.e(TAG, "getStatus: ${status.error}")
true -> status.success?.let { it ->
it.Peer?.values?.let { peers ->
val allNodes = peers.filter { it.ExitNodeOption }.map {
ExitNode(
id = it.ID,
label = it.DNSName,
online = it.Online,
selected = it.ExitNode,
mullvad = it.DNSName.endsWith(".mullvad.ts.net."),
priority = it.Location?.Priority ?: 0,
countryCode = it.Location?.CountryCode ?: "",
country = it.Location?.Country ?: "",
city = it.Location?.City ?: "",
)
}
val tailnetNodes = allNodes.filter { !it.mullvad }
tailnetExitNodes.set(tailnetNodes.sortedWith { a, b ->
a.label.compareTo(
b.label
)
})
val mullvadExitNodes = allNodes.filter {
// Pick all mullvad nodes that are online or the currently selected
it.mullvad && (it.selected || it.online)
}.groupBy {
// Group by countryCode
it.countryCode
}.mapValues { (_, nodes) ->
// Group by city
nodes.groupBy {
it.city
}.mapValues { (_, nodes) ->
// Pick one node per city, either the selected one or the best
// available
nodes.sortedWith { a, b ->
if (a.selected && !b.selected) {
-1
} else if (b.selected && !a.selected) {
1
} else {
b.priority.compareTo(a.priority)
}
}.first()
}.values.sortedBy { it.city.lowercase() }
}
mullvadExitNodesByCountryCode.set(mullvadExitNodes)
val bestAvailableByCountry = mullvadExitNodes.mapValues { (_, nodes) ->
nodes.minByOrNull { -1 * it.priority }!!
}
mullvadBestAvailableByCountry.set(bestAvailableByCountry)
anyActive.set(allNodes.any { it.selected })
}
}
}
}
}
}
fun setExitNode(node: ExitNode) {
LoadingIndicator.start()
model.setExitNodeId(node.id) {
onNavigateHome()
LoadingIndicator.stop()
}
}
}

@ -10,7 +10,7 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.set
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet
import kotlinx.coroutines.flow.MutableStateFlow

@ -12,7 +12,7 @@ import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.service.IpnManager
import com.tailscale.ipn.ui.service.set
import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.service.toggleCorpDNS
import com.tailscale.ipn.ui.view.SettingsNav
import kotlinx.coroutines.flow.MutableStateFlow

@ -11,6 +11,8 @@
<string name="not_connected">Not Connected</string>
<string name="empty"> </string>
<string name="template">%s</string>
<string name="more">More</string>
<string name="offline">offline</string>
<!-- Strings for the about screen -->
<string name="app_name">Tailscale</string>
@ -72,4 +74,10 @@
<string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</string>
<!-- Strings for ExitNode picker -->
<string name="choose_exit_node">Choose Exit Node</string>
<string name="tailnet_exit_nodes">Tailnet Exit Nodes</string>
<string name="mullvad_exit_nodes">Mullvad VPN</string>
<string name="best_available">Best Available</string>
</resources>

@ -9,6 +9,7 @@ import (
"encoding/json"
"io"
"log"
"runtime/debug"
"time"
"unsafe"
@ -47,6 +48,13 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
jbody C.jbyteArray,
jcookie C.jstring) {
defer func() {
if p := recover(); p != nil {
log.Printf("doRequest() panicked with %q, stack: %s", p, debug.Stack())
panic(p)
}
}()
jenv := (*jni.Env)(unsafe.Pointer(env))
// The API Path
@ -79,7 +87,7 @@ func doLocalAPIRequest(path string, method string, body []byte) []byte {
return []byte("{\"error\":\"Not Ready\"}")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var reader io.Reader = nil
if len(body) > 0 {
@ -87,11 +95,12 @@ func doLocalAPIRequest(path string, method string, body []byte) []byte {
}
r, err := shim.service.Call(ctx, method, path, reader)
defer r.Body().Close()
if err != nil {
log.Printf("error calling %s %q: %s", method, path, err)
return []byte("{\"error\":\"" + err.Error() + "\"}")
}
defer r.Body().Close()
respBytes, err := io.ReadAll(r.Body())
if err != nil {
return []byte("{\"error\":\"" + err.Error() + "\"}")

Loading…
Cancel
Save