diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index a0d6073..cce883d 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -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) } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt index ead26eb..a8f2656 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIClient.kt @@ -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 executeRequest(request: LocalAPIRequest) { - scope.launch { + private fun executeRequest(request: LocalAPIRequest) { + 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) -> Unit) { val req = LocalAPIRequest.editPrefs(prefs, responseHandler) - executeRequest(req) + executeRequest(req) } fun getProfiles(responseHandler: (Result>) -> 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(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(req) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt index 8203e21..4264357 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/LocalAPIRequest.kt @@ -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( - 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( val decoder = Json { ignoreUnknownKeys = true } fun get(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "GET", - path = path, - body = body, - parser = parser - ) + LocalAPIRequest( + method = "GET", path = path, body = body, parser = parser + ) fun put(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "PUT", - path = path, - body = body, - parser = parser - ) + LocalAPIRequest( + method = "PUT", path = path, body = body, parser = parser + ) fun post(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "POST", - path = path, - body = body, - parser = parser - ) + LocalAPIRequest( + method = "POST", path = path, body = body, parser = parser + ) fun patch(path: String, body: ByteArray? = null, parser: (ByteArray) -> Unit) = - LocalAPIRequest( - method = "PATCH", - path = path, - body = body, - parser = parser - ) + LocalAPIRequest( + method = "PATCH", path = path, body = body, parser = parser + ) fun status(responseHandler: StatusResponseHandler): LocalAPIRequest { return get(Endpoint.STATUS) { resp -> @@ -121,14 +107,15 @@ class LocalAPIRequest( } } - fun editPrefs(prefs: Ipn.MaskedPrefs, responseHandler: (Result) -> Unit): LocalAPIRequest { + fun editPrefs( + prefs: Ipn.MaskedPrefs, responseHandler: (Result) -> Unit + ): LocalAPIRequest { val body = Json.encodeToString(prefs).toByteArray() return patch(Endpoint.PREFS, body) { resp -> responseHandler(decode(resp)) } } - fun profiles(responseHandler: (Result>) -> Unit): LocalAPIRequest> { return get(Endpoint.PROFILES) { resp -> responseHandler(decode>(resp)) diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt index 9316986..ab723cd 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/Ipn.kt @@ -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? = null, - var AdvertiseTags: List? = 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? = null, + var AdvertiseTags: List? = 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 diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt index cf39c50..06ec633 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/IpnState.kt @@ -26,6 +26,7 @@ class IpnState { val Online: Boolean, val ExitNode: Boolean, val ExitNodeOption: Boolean, + val Active: Boolean, val PeerAPIURL: List? = null, val Capabilities: List? = null, val SSH_HostKeys: List? = null, diff --git a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt index 287d478..dbab647 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/model/TailCfg.kt @@ -49,6 +49,7 @@ class Tailcfg { var Machine: String? = null, var RoutableIPs: List? = null, var Services: List? = null, + var Location: Location? = null, ) @Serializable diff --git a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt index e4d7f05..3855624 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/notifier/Notifier.kt @@ -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(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 ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt index adfb90d..a7aed2d 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnManager.kt @@ -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) diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt index 257aa1d..175f645 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/IpnModel.kt @@ -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 StateFlow.set(v: T) { - (this as MutableStateFlow).value = v -} - class IpnModel( - notifier: Notifier, - val apiClient: LocalApiClient, - val scope: CoroutineScope + notifier: Notifier, val apiClient: LocalApiClient, val scope: CoroutineScope ) { private var notifierSessions: MutableList = 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}" + ) + } } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt b/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt index 0e6669f..bf5c506 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/service/PrefsEditor.kt @@ -53,9 +53,9 @@ fun IpnModel.toggleShieldsUp(callback: (Result) -> Unit) { apiClient.editPrefs(prefsOut, callback) } -fun IpnModel.setExitNodeId(id: String, callback: (Result) -> Unit) { +fun IpnModel.setExitNodeId(id: String?, callback: (Result) -> Unit) { val prefsOut = Ipn.MaskedPrefs() - prefsOut.ExitNodeId = id + prefsOut.ExitNodeID = id apiClient.editPrefs(prefsOut, callback) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Flag.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Flag.kt new file mode 100644 index 0000000..8e5b0fb --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Flag.kt @@ -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)) +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt b/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt new file mode 100644 index 0000000..1815276 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/LoadingIndicator.kt @@ -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() + } + + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/StateFlow.kt b/android/src/main/java/com/tailscale/ipn/ui/util/StateFlow.kt new file mode 100644 index 0000000..66f3cfe --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/util/StateFlow.kt @@ -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 StateFlow.set(v: T) { + (this as MutableStateFlow).value = v +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt index 983514d..d36e817 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt @@ -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) + } + } + }) } } \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt index a959778..dfd818f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt @@ -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, 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, ) { 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) + } ) } } } } } -} +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt new file mode 100644 index 0000000..714c878 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/MullvadExitNodePicker.kt @@ -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) + } + } + } + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt index 9c958a9..a873de0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/BugReportViewModel.kt @@ -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 = MutableStateFlow("") + val bugReportID: StateFlow = MutableStateFlow("") init { viewModelScope.launch { diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt index eb30bc3..888e7fb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/ExitNodePickerViewModel.kt @@ -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> = MutableStateFlow(emptyList()) + val mullvadExitNodesByCountryCode: StateFlow>> = MutableStateFlow( + TreeMap() + ) + val mullvadBestAvailableByCountry: StateFlow> = MutableStateFlow( + TreeMap() + ) + val anyActive: StateFlow = 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() + } + } } \ No newline at end of file diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index 69231e0..7a8d27a 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -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 diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt index 772573b..e76fdff 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/SettingsViewModel.kt @@ -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 diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 5ab3f94..e1c6d93 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -11,6 +11,8 @@ Not Connected %s + More + offline Tailscale @@ -72,4 +74,10 @@ in %d months in %.1f years + + Choose Exit Node + Tailnet Exit Nodes + Mullvad VPN + Best Available + diff --git a/cmd/localapiservice/localapishim.go b/cmd/localapiservice/localapishim.go index ef40e7a..e06a502 100644 --- a/cmd/localapiservice/localapishim.go +++ b/cmd/localapiservice/localapishim.go @@ -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() + "\"}")