android: implement exit node picker

Updates tailscale/corp#18202

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

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

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

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

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

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

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

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

@ -26,8 +26,7 @@ interface IpnActions {
} }
class IpnManager(scope: CoroutineScope) : IpnActions { class IpnManager(scope: CoroutineScope) : IpnActions {
private var notifier = Notifier() private var notifier = Notifier(scope)
var apiClient = LocalApiClient(scope) var apiClient = LocalApiClient(scope)
var mdmSettings = MDMSettings() var mdmSettings = MDMSettings()
val model = IpnModel(notifier, apiClient, scope) 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.IpnLocal
import com.tailscale.ipn.ui.model.Netmap import com.tailscale.ipn.ui.model.Netmap
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch 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( class IpnModel(
notifier: Notifier, notifier: Notifier, val apiClient: LocalApiClient, val scope: CoroutineScope
val apiClient: LocalApiClient,
val scope: CoroutineScope
) { ) {
private var notifierSessions: MutableList<String> = mutableListOf() private var notifierSessions: MutableList<String> = mutableListOf()
@ -50,13 +42,19 @@ class IpnModel(
LocalApiClient.isReady.await() LocalApiClient.isReady.await()
apiClient.getProfiles { result -> apiClient.getProfiles { result ->
result.success?.let(loginProfiles::set) result.success?.let(loginProfiles::set) ?: run {
?: run { Log.e("IpnManager", "Error loading profiles: ${result.error}") } Log.e(
"IpnManager", "Error loading profiles: ${result.error}"
)
}
} }
apiClient.getCurrentProfile { result -> apiClient.getCurrentProfile { result ->
result.success?.let(loggedInUser::set) result.success?.let(loggedInUser::set) ?: run {
?: run { Log.e("IpnManager", "Error loading current profile: ${result.error}") } 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) 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() val prefsOut = Ipn.MaskedPrefs()
prefsOut.ExitNodeId = id prefsOut.ExitNodeID = id
apiClient.editPrefs(prefsOut, callback) 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 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.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable 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 import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ExitNodePicker(viewModel: ExitNodePickerViewModel) { fun ExitNodePicker(
Column { viewModel: ExitNodePickerViewModel,
Text(text = "Future Home of Picking Exit Nodes") 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.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.PrimaryActionButton import com.tailscale.ipn.ui.util.PrimaryActionButton
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView // Navigation actions for the MainView
data class MainViewNavigation( data class MainViewNavigation(
val onNavigateToSettings: () -> Unit, val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit) val onNavigateToExitNodes: () -> Unit)
@Composable @Composable
fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) { fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
Surface(color = MaterialTheme.colorScheme.secondaryContainer) { Surface(color = MaterialTheme.colorScheme.secondaryContainer) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null) val user = viewModel.loggedInUser.collectAsState(initial = null)
Row(modifier = Modifier Row(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false) val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp)) Spacer(Modifier.size(3.dp))
StateDisplay(viewModel.stateRes, viewModel.userName) StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier Box(modifier = Modifier
.weight(1f) .weight(1f)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) { .clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
Avatar(profile = user.value, size = 36) Avatar(profile = user.value, size = 36)
} }
} }
@ -93,22 +94,22 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, stringResource(id = R.string.none)) ExitNodeStatus(navigation.onNavigateToExitNodes, viewModel)
PeerList( PeerList(
searchTerm = viewModel.searchTerm, searchTerm = viewModel.searchTerm,
state = viewModel.ipnState, state = viewModel.ipnState,
peers = viewModel.peers, peers = viewModel.peers,
selfPeer = viewModel.selfPeerId, selfPeer = viewModel.selfPeerId,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) }) onSearch = { viewModel.searchPeers(it) })
} }
Ipn.State.Starting -> StartingView() Ipn.State.Starting -> StartingView()
else -> else ->
ConnectView( ConnectView(
user.value, user.value,
{ viewModel.toggleVpn() }, { viewModel.toggleVpn() },
{ viewModel.login() } { viewModel.login() }
) )
} }
@ -117,20 +118,30 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
} }
@Composable @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 Box(modifier = Modifier
.clickable { navAction() } .clickable { navAction() }
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.secondaryContainer)
.fillMaxWidth()) { .fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) { Column(modifier = Modifier.padding(6.dp)) {
Text(text = stringResource(id = R.string.exit_node), style = MaterialTheme.typography.titleMedium) Text(text = stringResource(id = R.string.exit_node), style = MaterialTheme.typography.titleMedium)
Row { Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = exitNode, style = MaterialTheme.typography.bodyMedium) Text(text = exitNode ?: stringResource(id = R.string.none), style = MaterialTheme.typography.bodyMedium)
Icon( Icon(
Icons.Outlined.ArrowDropDown, Icons.Outlined.ArrowDropDown,
null, null,
) )
} }
} }
@ -152,12 +163,12 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) { fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar. // (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton( IconButton(
modifier = Modifier.size(24.dp), modifier = Modifier.size(24.dp),
onClick = { action() } onClick = { action() }
) { ) {
Icon( Icon(
Icons.Outlined.Settings, Icons.Outlined.Settings,
null, null,
) )
} }
} }
@ -166,16 +177,16 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
fun StartingView() { fun StartingView() {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation. // (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text(text = stringResource(id = R.string.starting), Text(text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
} }
} }
@ -280,54 +291,54 @@ fun PeerList(searchTerm: StateFlow<String>,
) { ) {
LazyColumn( LazyColumn(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer), .background(MaterialTheme.colorScheme.secondaryContainer),
) { ) {
peerList.value.forEach { peerSet -> peerList.value.forEach { peerSet ->
item { item {
ListItem(headlineContent = { ListItem(headlineContent = {
Text(text = peerSet.user?.DisplayName 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 -> peerSet.peers.forEach { peer ->
item { item {
ListItem( ListItem(
modifier = Modifier.clickable { modifier = Modifier.clickable {
onNavigateToPeerDetails(peer) onNavigateToPeerDetails(peer)
}, },
headlineContent = { headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected. // 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 isSelfAndRunning = (peer.StableID == selfPeer && stateVal.value == Ipn.State.Running)
val color: Color = if ((peer.Online == true) || isSelfAndRunning) { val color: Color = if ((peer.Online == true) || isSelfAndRunning) {
ts_color_light_green ts_color_light_green
} else { } else {
Color.Gray 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)
} }
}, Box(modifier = Modifier
supportingContent = { .size(8.dp)
Text( .background(color = color, shape = RoundedCornerShape(percent = 50))) {}
text = peer.Addresses?.first()?.split("/")?.first() Spacer(modifier = Modifier.size(8.dp))
?: "", Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
style = MaterialTheme.typography.bodyMedium
)
},
trailingContent = {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
} }
},
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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.LocalApiClient import com.tailscale.ipn.ui.localapi.LocalApiClient
import com.tailscale.ipn.ui.model.BugReportID import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.service.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() { class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() {
var bugReportID: StateFlow<BugReportID> = MutableStateFlow("") val bugReportID: StateFlow<String> = MutableStateFlow("")
init { init {
viewModelScope.launch { viewModelScope.launch {

@ -4,8 +4,118 @@
package com.tailscale.ipn.ui.viewModel package com.tailscale.ipn.ui.viewModel
import android.util.Log
import androidx.lifecycle.ViewModel 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.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.model.Ipn.State
import com.tailscale.ipn.ui.service.IpnActions import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel 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.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow

@ -12,7 +12,7 @@ import com.tailscale.ipn.BuildConfig
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.mdm.StringSetting import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.service.IpnManager 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.service.toggleCorpDNS
import com.tailscale.ipn.ui.view.SettingsNav import com.tailscale.ipn.ui.view.SettingsNav
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow

@ -11,6 +11,8 @@
<string name="not_connected">Not Connected</string> <string name="not_connected">Not Connected</string>
<string name="empty"> </string> <string name="empty"> </string>
<string name="template">%s</string> <string name="template">%s</string>
<string name="more">More</string>
<string name="offline">offline</string>
<!-- Strings for the about screen --> <!-- Strings for the about screen -->
<string name="app_name">Tailscale</string> <string name="app_name">Tailscale</string>
@ -72,4 +74,10 @@
<string name="in_x_months">in %d months</string> <string name="in_x_months">in %d months</string>
<string name="in_x_years">in %.1f years</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> </resources>

@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"io" "io"
"log" "log"
"runtime/debug"
"time" "time"
"unsafe" "unsafe"
@ -47,6 +48,13 @@ func Java_com_tailscale_ipn_ui_localapi_LocalApiClient_doRequest(
jbody C.jbyteArray, jbody C.jbyteArray,
jcookie C.jstring) { 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)) jenv := (*jni.Env)(unsafe.Pointer(env))
// The API Path // The API Path
@ -79,7 +87,7 @@ func doLocalAPIRequest(path string, method string, body []byte) []byte {
return []byte("{\"error\":\"Not Ready\"}") 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() defer cancel()
var reader io.Reader = nil var reader io.Reader = nil
if len(body) > 0 { 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) r, err := shim.service.Call(ctx, method, path, reader)
defer r.Body().Close()
if err != nil { if err != nil {
log.Printf("error calling %s %q: %s", method, path, err)
return []byte("{\"error\":\"" + err.Error() + "\"}") return []byte("{\"error\":\"" + err.Error() + "\"}")
} }
defer r.Body().Close()
respBytes, err := io.ReadAll(r.Body()) respBytes, err := io.ReadAll(r.Body())
if err != nil { if err != nil {
return []byte("{\"error\":\"" + err.Error() + "\"}") return []byte("{\"error\":\"" + err.Error() + "\"}")

Loading…
Cancel
Save