From a321d84dba72589e17a338df24ae86e463ace182 Mon Sep 17 00:00:00 2001 From: Jonathan Nobels Date: Mon, 1 Apr 2024 10:29:02 -0400 Subject: [PATCH] android/ui: add support for logging in using a custom control server (#266) fixes ENG-2871 Adds a menu option under the FUS for logging in with your custom control server. Follows the general pattern used on macOS. Signed-off-by: Jonathan Nobels --- .../com/tailscale/ipn/ui/localapi/Client.kt | 5 + .../java/com/tailscale/ipn/ui/model/Ipn.kt | 14 ++ .../com/tailscale/ipn/ui/view/ErrorDialog.kt | 4 + .../com/tailscale/ipn/ui/view/SharedViews.kt | 18 +- .../tailscale/ipn/ui/view/UserSwitcherView.kt | 180 +++++++++++++----- .../ipn/ui/viewModel/UserSwitcherViewModel.kt | 58 +++++- android/src/main/res/values/strings.xml | 9 +- 7 files changed, 230 insertions(+), 58 deletions(-) diff --git a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt index b313ba3..10729a6 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/localapi/Client.kt @@ -63,6 +63,11 @@ typealias PrefsHandler = (Result) -> Unit class Client(private val scope: CoroutineScope) { private val TAG = Client::class.simpleName + fun start(options: Ipn.Options, responseHandler: (Result) -> Unit) { + val body = Json.encodeToString(options).toByteArray() + return post(Endpoint.START, body, responseHandler = responseHandler) + } + fun status(responseHandler: StatusResponseHandler) { get(Endpoint.STATUS, responseHandler = responseHandler) } 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 9c95294..2872660 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 @@ -69,6 +69,7 @@ class Ipn { @Serializable data class MaskedPrefs( + var ControlURLSet: Boolean? = null, var RouteAllSet: Boolean? = null, var CorpDNSSet: Boolean? = null, var ExitNodeIDSet: Boolean? = null, @@ -79,6 +80,13 @@ class Ipn { var ForceDaemonSet: Boolean? = null, var HostnameSet: Boolean? = null, ) { + + var ControlURL: String? = null + set(value) { + field = value + ControlURLSet = true + } + var RouteAll: Boolean? = null set(value) { field = value @@ -182,6 +190,12 @@ class Ipn { } @Serializable data class FileTarget(var Node: Tailcfg.Node, var PeerAPIURL: String) + + @Serializable + data class Options( + var FrontendLogID: String? = null, + var Prefs: Prefs? = null, + ) } class Persist { diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt index b389b0c..0615edb 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt @@ -10,7 +10,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.tailscale.ipn.R + enum class ErrorDialogType { + INVALID_CUSTOM_URL, LOGOUT_FAILED, SWITCH_USER_FAILED, ADD_PROFILE_FAILED, @@ -20,6 +22,7 @@ enum class ErrorDialogType { val message: Int get() { return when (this) { + INVALID_CUSTOM_URL -> R.string.invalidCustomUrl LOGOUT_FAILED -> R.string.logout_failed SWITCH_USER_FAILED -> R.string.switch_user_failed ADD_PROFILE_FAILED -> R.string.add_profile_failed @@ -31,6 +34,7 @@ enum class ErrorDialogType { val title: Int get() { return when (this) { + INVALID_CUSTOM_URL -> R.string.invalidCustomURLTitle LOGOUT_FAILED -> R.string.logout_failed_title SWITCH_USER_FAILED -> R.string.switch_user_failed_title ADD_PROFILE_FAILED -> R.string.add_profile_failed_title diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt index 6b90218..8b24ad1 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SharedViews.kt @@ -5,6 +5,7 @@ package com.tailscale.ipn.ui.view import androidx.annotation.StringRes import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons @@ -29,15 +30,22 @@ data class BackNavigation( ) // Header view for all secondary screens +// @see TopAppBar actions for additional actions (usually a row of icons) @OptIn(ExperimentalMaterial3Api::class) @Composable -fun Header(@StringRes title: Int = 0, titleText: String? = null, onBack: (() -> Unit)? = null) { +fun Header( + @StringRes title: Int = 0, + titleText: String? = null, + actions: @Composable RowScope.() -> Unit = {}, + onBack: (() -> Unit)? = null +) { TopAppBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surfaceContainer, titleContentColor = MaterialTheme.colorScheme.primary, ), + actions = actions, title = { Text(titleText ?: stringResource(title)) }, navigationIcon = { onBack?.let { BackArrow(action = it) } }, ) @@ -68,9 +76,9 @@ fun SimpleActivityIndicator(size: Int = 32) { @Composable fun ActivityIndicator(progress: Double, size: Int = 32) { LinearProgressIndicator( - progress = { progress.toFloat() }, - modifier = Modifier.width(size.dp), - color = ts_color_light_blue, - trackColor = MaterialTheme.colorScheme.secondary, + progress = { progress.toFloat() }, + modifier = Modifier.width(size.dp), + color = ts_color_light_blue, + trackColor = MaterialTheme.colorScheme.secondary, ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt index b5ac54c..95877b3 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserSwitcherView.kt @@ -3,18 +3,35 @@ package com.tailscale.ipn.ui.view +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.tailscale.ipn.R @@ -25,7 +42,7 @@ import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable -fun UserSwitcherView( +fun UserSwitcherView( nav: BackNavigation, onNavigateHome: () -> Unit, viewModel: UserSwitcherViewModel = viewModel() @@ -33,60 +50,125 @@ fun UserSwitcherView( val users = viewModel.loginProfiles.collectAsState().value val currentUser = viewModel.loggedInUser.collectAsState().value + val showHeaderMenu = viewModel.showHeaderMenu.collectAsState().value - Scaffold(topBar = { Header(R.string.accounts, onBack = nav.onBack) }) { innerPadding -> - Column( - modifier = Modifier.padding(innerPadding).fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp)) { - val showDialog = viewModel.showDialog.collectAsState().value + Scaffold( + topBar = { + Header( + R.string.accounts, + onBack = nav.onBack, + actions = { + Row { + FusMenu(viewModel = viewModel) + IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) { + Icon(Icons.Default.MoreVert, "menu") + } + } + }) + }) { innerPadding -> + Column( + modifier = Modifier.padding(innerPadding).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp)) { + val showErrorDialog = viewModel.errorDialog.collectAsState().value - // Show the error overlay if need be - showDialog?.let { ErrorDialog(type = it, action = { viewModel.showDialog.set(null) }) } + // Show the error overlay if need be + showErrorDialog?.let { + ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) + } - // When switch is invoked, this stores the ID of the user we're trying to switch to - // so we can decorate it with a spinner. The actual logged in user will not change - // until - // we get our first netmap update back with the new userId for SelfNode. - // (jonathan) TODO: This user switch is not immediate. We may need to represent the - // "switching users" state globally (if ipnState is insufficient) - val nextUserId = remember { mutableStateOf(null) } + // When switch is invoked, this stores the ID of the user we're trying to switch to + // so we can decorate it with a spinner. The actual logged in user will not change + // until + // we get our first netmap update back with the new userId for SelfNode. + // (jonathan) TODO: This user switch is not immediate. We may need to represent the + // "switching users" state globally (if ipnState is insufficient) + val nextUserId = remember { mutableStateOf(null) } - LazyColumn { - itemsWithDividers(users ?: emptyList()) { user -> - if (user.ID == currentUser?.ID) { - UserView(profile = user, actionState = UserActionState.CURRENT) - } else { - val state = - if (user.ID == nextUserId.value) UserActionState.SWITCHING - else UserActionState.NONE - UserView( - profile = user, - actionState = state, - onClick = { - nextUserId.value = user.ID - viewModel.switchProfile(user) { - if (it.isFailure) { - viewModel.showDialog.set(ErrorDialogType.LOGOUT_FAILED) - nextUserId.value = null - } else { - onNavigateHome() - } - } - }) + LazyColumn { + itemsWithDividers(users ?: emptyList()) { user -> + if (user.ID == currentUser?.ID) { + UserView(profile = user, actionState = UserActionState.CURRENT) + } else { + val state = + if (user.ID == nextUserId.value) UserActionState.SWITCHING + else UserActionState.NONE + UserView( + profile = user, + actionState = state, + onClick = { + nextUserId.value = user.ID + viewModel.switchProfile(user) { + if (it.isFailure) { + viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED) + nextUserId.value = null + } else { + onNavigateHome() + } + } + }) + } + } + + item { + Lists.SectionDivider() + SettingRow(viewModel.addProfileSetting) + Lists.ItemDivider() + SettingRow(viewModel.loginSetting) + if (currentUser != null) { + Lists.ItemDivider() + SettingRow(viewModel.logoutSetting) + } + } } } + } +} + +@Composable +fun FusMenu(viewModel: UserSwitcherViewModel) { + var url by remember { mutableStateOf("") } + val expanded = viewModel.showHeaderMenu.collectAsState().value + + DropdownMenu( + expanded = expanded, + onDismissRequest = { + url = "" + viewModel.showHeaderMenu.set(false) + }, + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { + DropdownMenuItem( + onClick = {}, + text = { + Column { + Text( + stringResource(id = R.string.custom_control_menu), + style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.padding(2.dp)) + Text( + stringResource(id = R.string.custom_control_menu_desc), + style = MaterialTheme.typography.bodySmall) + Spacer(modifier = Modifier.padding(8.dp)) + + OutlinedTextField( + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent), + textStyle = MaterialTheme.typography.bodyMedium, + value = url, + onValueChange = { url = it }, + placeholder = { + Text( + stringResource(id = R.string.custom_control_placeholder), + style = MaterialTheme.typography.bodySmall) + }) + + Spacer(modifier = Modifier.padding(8.dp)) - item { - Lists.SectionDivider() - SettingRow(viewModel.addProfileSetting) - Lists.ItemDivider() - SettingRow(viewModel.loginSetting) - if (currentUser != null){ - Lists.ItemDivider() - SettingRow(viewModel.logoutSetting) + PrimaryActionButton(onClick = { viewModel.setControlURL(url) }) { + Text(stringResource(id = R.string.add_account_short)) + } } - } - } - } - } + }) + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt index 29c4a3c..bb2ac9c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/UserSwitcherViewModel.kt @@ -3,7 +3,11 @@ package com.tailscale.ipn.ui.viewModel +import androidx.lifecycle.viewModelScope import com.tailscale.ipn.R +import com.tailscale.ipn.ui.localapi.Client +import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.view.ErrorDialogType import kotlinx.coroutines.flow.MutableStateFlow @@ -12,7 +16,10 @@ import kotlinx.coroutines.flow.StateFlow class UserSwitcherViewModel : IpnViewModel() { // Set to a non-null value to show the appropriate error dialog - val showDialog: StateFlow = MutableStateFlow(null) + val errorDialog: StateFlow = MutableStateFlow(null) + + // True if we should render the kebab menu + val showHeaderMenu: StateFlow = MutableStateFlow(false) val loginSetting = Setting(titleRes = R.string.reauthenticate, type = SettingType.NAV, onClick = { login {} }) @@ -25,7 +32,7 @@ class UserSwitcherViewModel : IpnViewModel() { onClick = { logout { if (it.isFailure) { - showDialog.set(ErrorDialogType.LOGOUT_FAILED) + errorDialog.set(ErrorDialogType.LOGOUT_FAILED) } } }) @@ -37,8 +44,53 @@ class UserSwitcherViewModel : IpnViewModel() { onClick = { addProfile { if (it.isFailure) { - showDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) + errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) } } }) + + // Sets the custom control URL and immediatly invokes the login flow + fun setControlURL(urlStr: String) { + // Some basic checks that the entered URL is "reasonable". The underlying + // localAPIClient will use the default server if we give it a broken URL, + // but we can make sure we can construct a URL from the input string and + // ensure it has an http/https scheme + + when (urlStr.startsWith("http") && urlStr.contains("://") && urlStr.length > 7) { + false -> errorDialog.set(ErrorDialogType.INVALID_CUSTOM_URL) + true -> { + showHeaderMenu.set(false) + + // We need to have the current prefs to set them back with the new control URL + val prefs = Notifier.prefs.value + if (prefs == null) { + errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) + return + } + + // The basic flow for logging in with a custom control URL is to add a profile, + // call start with prefs that include the control URL pref, then + // start an interactive login. + + val fail: (Throwable) -> Unit = { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) } + + val login = { + Client(viewModelScope).startLoginInteractive { startLogin -> startLogin.onFailure(fail) } + } + + val start = { + prefs.ControlURL = urlStr + val options = Ipn.Options(Prefs = prefs) + + Client(viewModelScope).start(options) { start -> + start.onFailure(fail).onSuccess { login() } + } + } + + Client(viewModelScope).addProfile { addProfile -> + addProfile.onFailure(fail).onSuccess { start() } + } + } + } + } } diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 3a1ecf7..c23fa46 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -84,10 +84,16 @@ Unable to logout at this time. Please try again. Error Accounts - Add Another Account… + Add Another Account + Add Account Reauthenticate Unable to switch users. Please try again. Unable to add a new profile. Please try again. + Please enter a valid URL in the form https://server.com + Invalid URL + Add account using alternate server + Enter the URL of an alternate Tailscale server or your self-hosted Headscale server. + https://my.custom.server.com Choose Exit Node @@ -205,4 +211,5 @@ Get Started Tailscale is a mesh VPN for securely connecting your devices. All connections are device-to-device, so we never see your data.\n\nWe collect and use your email address and name, as well as your device name, OS version, and IP address in order to help you to connect your devices and manage your settings. We log when you are connected to your network.\n +