diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index 2924c3a..0d88d59 100644 --- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt +++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt @@ -53,6 +53,8 @@ import com.tailscale.ipn.ui.view.DNSSettingsView import com.tailscale.ipn.ui.view.ExitNodePicker import com.tailscale.ipn.ui.view.IntroView import com.tailscale.ipn.ui.view.LoginQRView +import com.tailscale.ipn.ui.view.LoginWithAuthKeyView +import com.tailscale.ipn.ui.view.LoginWithCustomControlURLView import com.tailscale.ipn.ui.view.MDMSettingsDebugView import com.tailscale.ipn.ui.view.MainView import com.tailscale.ipn.ui.view.MainViewNavigation @@ -64,6 +66,7 @@ import com.tailscale.ipn.ui.view.PermissionsView import com.tailscale.ipn.ui.view.RunExitNodeView import com.tailscale.ipn.ui.view.SettingsView import com.tailscale.ipn.ui.view.TailnetLockSetupView +import com.tailscale.ipn.ui.view.UserSwitcherNav import com.tailscale.ipn.ui.view.UserSwitcherView import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav import com.tailscale.ipn.ui.viewModel.SettingsNav @@ -161,6 +164,15 @@ class MainActivity : ComponentActivity() { onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") }, onNavigateToRunAsExitNode = { navController.navigate("runExitNode") }) + val userSwitcherNav = + UserSwitcherNav( + backToSettings = backTo("settings"), + onNavigateHome = backTo("main"), + onNavigateCustomControl = { + navController.navigate("loginWithCustomControl") + }, + onNavigateToAuthKey = { navController.navigate("loginWithAuthKey") }) + composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) { MainView(loginAtUrl = ::login, navigation = mainViewNav) } @@ -186,15 +198,20 @@ class MainActivity : ComponentActivity() { composable("about") { AboutView(backTo("settings")) } composable("mdmSettings") { MDMSettingsDebugView(backTo("settings")) } composable("managedBy") { ManagedByView(backTo("settings")) } - composable("userSwitcher") { - UserSwitcherView(backTo("settings"), backTo("main")) - } + composable("userSwitcher") { UserSwitcherView(userSwitcherNav) } composable("permissions") { PermissionsView(backTo("settings"), ::openApplicationSettings) } composable("intro", exitTransition = { fadeOut(animationSpec = tween(150)) }) { IntroView(backTo("main")) } + composable("loginWithAuthKey") { + LoginWithAuthKeyView(onNavigateHome = backTo("main"), backTo("userSwitcher")) + } + composable("loginWithCustomControl") { + LoginWithCustomControlURLView( + onNavigateHome = backTo("main"), backTo("userSwitcher")) + } } // Show the intro screen one time 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 2872660..087e0d5 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 @@ -194,7 +194,8 @@ class Ipn { @Serializable data class Options( var FrontendLogID: String? = null, - var Prefs: Prefs? = null, + var UpdatePrefs: Prefs? = null, + var AuthKey: String? = null, ) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt index 1b80163..5f7113f 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Buttons.kt @@ -6,19 +6,12 @@ package com.tailscale.ipn.ui.view import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Clear -import androidx.compose.material.icons.outlined.Close import androidx.compose.material3.Button -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp @@ -46,19 +39,3 @@ fun OpenURLButton(title: String, url: String) { ) } } - -@Composable -fun ClearButton(onClick: () -> Unit) { - IconButton(onClick = onClick, modifier = Modifier.size(24.dp)) { - Icon(Icons.Outlined.Clear, null) - } -} - -@Composable -fun CloseButton() { - val focusManager = LocalFocusManager.current - - IconButton(onClick = { focusManager.clearFocus() }, modifier = Modifier.size(24.dp)) { - Icon(Icons.Outlined.Close, null) - } -} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt new file mode 100644 index 0000000..5724a75 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/CustomLogin.kt @@ -0,0 +1,153 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.view + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ListItem +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 com.tailscale.ipn.R +import com.tailscale.ipn.ui.theme.listItem +import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.viewModel.LoginWithAuthKeyViewModel +import com.tailscale.ipn.ui.viewModel.LoginWithCustomControlURLViewModel + +data class LoginViewStrings( + var title: String, + var explanation: String, + var inputTitle: String, + var placeholder: String, +) + +@Composable +fun LoginWithCustomControlURLView( + onNavigateHome: BackNavigation, + backToSettings: BackNavigation, + viewModel: LoginWithCustomControlURLViewModel = LoginWithCustomControlURLViewModel() +) { + + Scaffold( + topBar = { + Header( + R.string.add_account, + onBack = backToSettings, + ) + }) { innerPadding -> + val error = viewModel.errorDialog.collectAsState().value + val strings = + LoginViewStrings( + title = stringResource(id = R.string.custom_control_menu), + explanation = stringResource(id = R.string.custom_control_menu_desc), + inputTitle = stringResource(id = R.string.custom_control_url_title), + placeholder = stringResource(id = R.string.custom_control_placeholder), + ) + + error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) } + + LoginView( + innerPadding = innerPadding, + strings = strings, + onSubmitAction = { viewModel.setControlURL(it, onNavigateHome) }) + } +} + +@Composable +fun LoginWithAuthKeyView( + onNavigateHome: BackNavigation, + backToSettings: BackNavigation, + viewModel: LoginWithAuthKeyViewModel = LoginWithAuthKeyViewModel() +) { + + Scaffold( + topBar = { + Header( + R.string.add_account, + onBack = backToSettings, + ) + }) { innerPadding -> + val error = viewModel.errorDialog.collectAsState().value + val strings = + LoginViewStrings( + title = stringResource(id = R.string.auth_key_title), + explanation = stringResource(id = R.string.auth_key_explanation), + inputTitle = stringResource(id = R.string.auth_key_input_title), + placeholder = stringResource(id = R.string.auth_key_placeholder), + ) + // Show the error overlay if need be + error?.let { ErrorDialog(type = it, action = { viewModel.errorDialog.set(null) }) } + + LoginView( + innerPadding = innerPadding, + strings = strings, + onSubmitAction = { viewModel.setAuthKey(it, onNavigateHome) }) + } +} + +@Composable +fun LoginView( + innerPadding: PaddingValues = PaddingValues(16.dp), + strings: LoginViewStrings, + onSubmitAction: (String) -> Unit, +) { + + var textVal by remember { mutableStateOf("") } + + Column( + modifier = + Modifier.padding(innerPadding) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface)) { + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { Text(text = strings.title) }, + supportingContent = { Text(text = strings.explanation) }) + + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { Text(text = strings.inputTitle) }, + supportingContent = { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + colors = + TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent), + textStyle = MaterialTheme.typography.bodyMedium, + value = textVal, + onValueChange = { textVal = it }, + placeholder = { + Text(strings.placeholder, style = MaterialTheme.typography.bodySmall) + }) + }) + + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Box(modifier = Modifier.fillMaxWidth()) { + Button( + onClick = { onSubmitAction(textVal) }, + content = { Text(stringResource(id = R.string.add_account_short)) }) + } + }) + } +} 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 24aa907..2e2750a 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 @@ -19,7 +19,8 @@ enum class ErrorDialogType { SWITCH_USER_FAILED, ADD_PROFILE_FAILED, SHARE_DEVICE_NOT_CONNECTED, - SHARE_FAILED; + SHARE_FAILED, + INVALID_AUTH_KEY; val message: Int get() { @@ -30,6 +31,7 @@ enum class ErrorDialogType { ADD_PROFILE_FAILED -> R.string.add_profile_failed SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected SHARE_FAILED -> R.string.taildrop_share_failed + INVALID_AUTH_KEY -> R.string.invalidAuthKey } } @@ -42,6 +44,7 @@ enum class ErrorDialogType { ADD_PROFILE_FAILED -> R.string.add_profile_failed_title SHARE_DEVICE_NOT_CONNECTED -> R.string.share_device_not_connected_title SHARE_FAILED -> R.string.taildrop_share_failed_title + INVALID_AUTH_KEY -> R.string.invalidAuthKeyTitle } } 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 7123ee3..f41628c 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 @@ -154,7 +154,7 @@ fun MainView( PromptPermissionsIfNecessary() - ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login {} }) + ExpiryNotificationIfNecessary(netmap = netmap, action = { viewModel.login() }) ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) @@ -170,7 +170,7 @@ fun MainView( state, user, { viewModel.toggleVpn() }, - { viewModel.login {} }, + { viewModel.login() }, loginAtUrl, netmap?.SelfNode) } 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 9599411..7387e79 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 @@ -7,7 +7,6 @@ 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 @@ -19,18 +18,13 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -41,13 +35,16 @@ import com.tailscale.ipn.ui.util.itemsWithDividers import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel +data class UserSwitcherNav( + val backToSettings: BackNavigation, + val onNavigateHome: () -> Unit, + val onNavigateCustomControl: () -> Unit, + val onNavigateToAuthKey: () -> Unit +) + @OptIn(ExperimentalMaterial3Api::class) @Composable -fun UserSwitcherView( - backToSettings: BackNavigation, - onNavigateHome: () -> Unit, - viewModel: UserSwitcherViewModel = viewModel() -) { +fun UserSwitcherView(nav: UserSwitcherNav, viewModel: UserSwitcherViewModel = viewModel()) { val users = viewModel.loginProfiles.collectAsState().value val currentUser = viewModel.loggedInUser.collectAsState().value @@ -57,10 +54,13 @@ fun UserSwitcherView( topBar = { Header( R.string.accounts, - onBack = backToSettings, + onBack = nav.backToSettings, actions = { Row { - FusMenu(viewModel = viewModel) + FusMenu( + viewModel = viewModel, + onAuthKeyClick = nav.onNavigateToAuthKey, + onCustomClick = nav.onNavigateCustomControl) IconButton(onClick = { viewModel.showHeaderMenu.set(!showHeaderMenu) }) { Icon(Icons.Default.MoreVert, "menu") } @@ -103,7 +103,7 @@ fun UserSwitcherView( viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED) nextUserId.value = null } else { - onNavigateHome() + nav.onNavigateHome() } } }) @@ -121,7 +121,7 @@ fun UserSwitcherView( } Lists.ItemDivider() - Setting.Text(R.string.reauthenticate) { viewModel.login {} } + Setting.Text(R.string.reauthenticate) { viewModel.login() } if (currentUser != null) { Lists.ItemDivider() @@ -130,9 +130,10 @@ fun UserSwitcherView( destructive = true, onClick = { viewModel.logout { - if (it.isFailure) { - viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED) - } + it.onSuccess { nav.onNavigateHome() } + .onFailure { + viewModel.errorDialog.set(ErrorDialogType.LOGOUT_FAILED) + } } }) } @@ -143,57 +144,47 @@ fun UserSwitcherView( } @Composable -fun FusMenu(viewModel: UserSwitcherViewModel) { - var url by remember { mutableStateOf("") } +fun FusMenu( + onCustomClick: () -> Unit, + onAuthKeyClick: () -> Unit, + viewModel: UserSwitcherViewModel +) { val expanded = viewModel.showHeaderMenu.collectAsState().value DropdownMenu( expanded = expanded, - onDismissRequest = { - url = "" - viewModel.showHeaderMenu.set(false) - }, + onDismissRequest = { 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)) - - PrimaryActionButton(onClick = { viewModel.setControlURL(url) }) { - Text(stringResource(id = R.string.add_account_short)) - } - } - }) + MenuItem( + onClick = { + onCustomClick() + viewModel.showHeaderMenu.set(false) + }, + text = stringResource(id = R.string.custom_control_menu)) + MenuItem( + onClick = { + onAuthKeyClick() + viewModel.showHeaderMenu.set(false) + }, + text = stringResource(id = R.string.auth_key_menu)) } } +@Composable +fun MenuItem(text: String, onClick: () -> Unit) { + DropdownMenuItem( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp), onClick = onClick, text = { Text(text = text) }) +} + @Composable @Preview fun UserSwitcherViewPreview() { val vm = UserSwitcherViewModel() - UserSwitcherView(backToSettings = {}, onNavigateHome = {}, vm) + val nav = + UserSwitcherNav( + backToSettings = {}, + onNavigateHome = {}, + onNavigateCustomControl = {}, + onNavigateToAuthKey = {}) + UserSwitcherView(nav, vm) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/CustomLoginViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/CustomLoginViewModel.kt new file mode 100644 index 0000000..0e830e6 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/CustomLoginViewModel.kt @@ -0,0 +1,52 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn.ui.viewModel + +import com.tailscale.ipn.ui.util.set +import com.tailscale.ipn.ui.view.ErrorDialogType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +const val AUTH_KEY_LENGTH = 16 + +open class CustomLoginViewModel : IpnViewModel() { + val errorDialog: StateFlow = MutableStateFlow(null) +} + +class LoginWithAuthKeyViewModel : CustomLoginViewModel() { + // Sets the auth key and invokes the login flow + fun setAuthKey(authKey: String, onSuccess: () -> Unit) { + // The most basic of checks for auth key syntax + if (authKey.isEmpty()) { + errorDialog.set(ErrorDialogType.INVALID_AUTH_KEY) + return + } + loginWithAuthKey(authKey) { + it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) } + it.onSuccess { onSuccess() } + } + } +} + +class LoginWithCustomControlURLViewModel : CustomLoginViewModel() { + // Sets the custom control URL and invokes the login flow + fun setControlURL(urlStr: String, onSuccess: () -> Unit) { + // 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) + return + } + true -> { + loginWithCustomControlURL(urlStr) { + it.onFailure { errorDialog.set(ErrorDialogType.ADD_PROFILE_FAILED) } + it.onSuccess { onSuccess() } + } + } + } + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt index 9636a43..9d40b05 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/IpnViewModel.kt @@ -3,9 +3,6 @@ package com.tailscale.ipn.ui.viewModel -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper import android.content.Intent import android.util.Log import androidx.lifecycle.ViewModel @@ -60,26 +57,7 @@ open class IpnViewModel : ViewModel() { Log.d(TAG, "Created") } - protected fun Context.findActivity(): Activity? = - when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null - } - - private fun loadUserProfiles() { - Client(viewModelScope).profiles { result -> - result.onSuccess(loginProfiles::set).onFailure { - Log.e(TAG, "Error loading profiles: ${it.message}") - } - } - - Client(viewModelScope).currentProfile { result -> - result - .onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) } - .onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") } - } - } + // VPN Control fun toggleVpn() { when (Notifier.state.value) { @@ -102,12 +80,58 @@ open class IpnViewModel : ViewModel() { context.sendBroadcast(intent) } - fun login(completionHandler: (Result) -> Unit = {}) { - Client(viewModelScope).startLoginInteractive { result -> - result - .onSuccess { Log.d(TAG, "Login started: $it") } - .onFailure { Log.e(TAG, "Error starting login: ${it.message}") } - completionHandler(result) + // Login/Logout + + fun login(options: Ipn.Options = Ipn.Options(), completionHandler: (Result) -> Unit = {}) { + + val loginAction = { + Client(viewModelScope).startLoginInteractive { result -> + result + .onSuccess { Log.d(TAG, "Login started: $it") } + .onFailure { Log.e(TAG, "Error starting login: ${it.message}") } + completionHandler(result) + } + } + + Client(viewModelScope).start(options) { start -> + start.onFailure { completionHandler(Result.failure(it)) }.onSuccess { loginAction() } + } + } + + fun loginWithAuthKey(authKey: String, completionHandler: (Result) -> Unit = {}) { + val prefs = Notifier.prefs.value + if (prefs == null) { + completionHandler(Result.failure(Error("no prefs"))) + return + } + + prefs.WantRunning = true + val options = Ipn.Options(AuthKey = authKey, UpdatePrefs = prefs) + login(options, completionHandler) + } + + fun loginWithCustomControlURL( + controlURL: String, + completionHandler: (Result) -> Unit = {} + ) { + val fail: (Throwable) -> Unit = { completionHandler(Result.failure(it)) } + + // We need to have the current prefs to set them back with the new control URL + val prefs = Notifier.prefs.value + if (prefs == null) { + fail(Error("no prefs")) + return + } + + // The flow for logging in with a custom control URL is to add a profile, + // call start with prefs that include the control URL, then + // start an interactive login. + Client(viewModelScope).addProfile { addProfile -> + addProfile.onFailure(fail).onSuccess { + prefs.ControlURL = controlURL + val options = Ipn.Options(UpdatePrefs = prefs) + login(options, completionHandler) + } } } @@ -120,6 +144,22 @@ open class IpnViewModel : ViewModel() { } } + // User Profiles + + private fun loadUserProfiles() { + Client(viewModelScope).profiles { result -> + result.onSuccess(loginProfiles::set).onFailure { + Log.e(TAG, "Error loading profiles: ${it.message}") + } + } + + Client(viewModelScope).currentProfile { result -> + result + .onSuccess { loggedInUser.set(if (it.isEmpty()) null else it) } + .onFailure { Log.e(TAG, "Error loading current profile: ${it.message}") } + } + } + fun switchProfile(profile: IpnLocal.LoginProfile, completionHandler: (Result) -> Unit) { Client(viewModelScope).switchProfile(profile) { startVPN() @@ -130,7 +170,7 @@ open class IpnViewModel : ViewModel() { fun addProfile(completionHandler: (Result) -> Unit) { Client(viewModelScope).addProfile { if (it.isSuccess) { - login {} + login() } startVPN() completionHandler(it) @@ -143,52 +183,4 @@ open class IpnViewModel : ViewModel() { completionHandler(it) } } - - // The below handle all types of preference modifications typically invoked by the UI. - // Callers generally shouldn't care about the returned prefs value - the source of - // truth is the IPNModel, who's prefs flow will change in value to reflect the true - // value of the pref setting in the back end (and will match the value returned here). - // Generally, you will want to inspect the returned value in the callback for errors - // to indicate why a particular setting did not change in the interface. - // - // Usage: - // - User/Interface changed to new value. Render the new value. - // - Submit the new value to the PrefsEditor - // - Observe the prefs on the IpnModel and update the UI when/if the value changes. - // For a typical flow, the changed value should reflect the value already shown. - // - Inform the user of any error which may have occurred - // - // The "toggle' functions here will attempt to set the pref value to the inverse of - // what is currently known in the IpnModel.prefs. If IpnModel.prefs is not available, - // the callback will be called with a NO_PREFS error - fun setWantRunning(wantRunning: Boolean, callback: (Result) -> Unit) { - Ipn.MaskedPrefs().WantRunning = wantRunning - Client(viewModelScope).editPrefs(Ipn.MaskedPrefs(), callback) - } - - fun toggleShieldsUp(callback: (Result) -> Unit) { - val prefs = - Notifier.prefs.value - ?: run { - callback(Result.failure(Exception("no prefs"))) - return@toggleShieldsUp - } - - val prefsOut = Ipn.MaskedPrefs() - prefsOut.ShieldsUp = !prefs.ShieldsUp - Client(viewModelScope).editPrefs(prefsOut, callback) - } - - fun toggleRouteAll(callback: (Result) -> Unit) { - val prefs = - Notifier.prefs.value - ?: run { - callback(Result.failure(Exception("no prefs"))) - return@toggleRouteAll - } - - val prefsOut = Ipn.MaskedPrefs() - prefsOut.RouteAll = !prefs.RouteAll - Client(viewModelScope).editPrefs(prefsOut, callback) - } } 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 d468fe3..3231d68 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,11 +3,6 @@ package com.tailscale.ipn.ui.viewModel -import androidx.lifecycle.viewModelScope -import com.tailscale.ipn.ui.localapi.Client -import com.tailscale.ipn.ui.model.Ipn -import com.tailscale.ipn.ui.notifier.Notifier -import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.view.ErrorDialogType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -19,49 +14,4 @@ class UserSwitcherViewModel : IpnViewModel() { // True if we should render the kebab menu val showHeaderMenu: StateFlow = MutableStateFlow(false) - - // 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 5b43580..08c6bfa 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -105,16 +105,24 @@ Error Accounts Manage 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 + Use an alternate server Enter the URL of an alternate Tailscale server or your self-hosted Headscale server. https://my.custom.server.com + Use an auth key + Add an account using an auth key + Enter a valid auth key generated by the Tailscale admin console + ie: tskey-auth-mYta1ln3tCNTRL-s3cr3tauthk3yFr0madM1n + The provided auth key is not in the correct format. + Invalid key + Custom control server URL + Auth key Choose exit node