From 967fdc3449b632d91181e02c85a3ee7d38680254 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Tue, 31 Oct 2023 11:12:01 -0500 Subject: [PATCH] BIT-330: Implement self-hosting/custom environment UI (#184) --- .../ui/auth/feature/auth/AuthNavigation.kt | 8 + .../environment/EnvironmentNavigation.kt | 33 +++ .../feature/environment/EnvironmentScreen.kt | 201 ++++++++++++++++++ .../environment/EnvironmentViewModel.kt | 181 ++++++++++++++++ .../auth/feature/landing/LandingNavigation.kt | 2 + .../ui/auth/feature/landing/LandingScreen.kt | 3 + .../auth/feature/landing/LandingViewModel.kt | 12 +- .../platform/components/BitwardenTextField.kt | 8 + .../environment/EnvironmentScreenTest.kt | 200 +++++++++++++++++ .../environment/EnvironmentViewModelTest.kt | 151 +++++++++++++ .../auth/feature/landing/LandingScreenTest.kt | 30 +++ 11 files changed, 825 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt index 09e02b71c..b6d64e058 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/auth/AuthNavigation.kt @@ -12,6 +12,8 @@ import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestinations import com.x8bit.bitwarden.ui.auth.feature.login.loginDestinations import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin +import com.x8bit.bitwarden.ui.auth.feature.environment.navigateToEnvironment +import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination const val AUTH_GRAPH_ROUTE: String = "auth_graph" @@ -43,10 +45,16 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { captchaToken = null, ) }, + onNavigateToEnvironment = { + navController.navigateToEnvironment() + }, ) loginDestinations( onNavigateBack = { navController.popBackStack() }, ) + environmentDestination( + onNavigateBack = { navController.popBackStack() }, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentNavigation.kt new file mode 100644 index 000000000..61db3e473 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentNavigation.kt @@ -0,0 +1,33 @@ +package com.x8bit.bitwarden.ui.auth.feature.environment + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders + +private const val ENVIRONMENT_ROUTE = "environment" + +/** + * Add settings destinations to the nav graph. + */ +fun NavGraphBuilder.environmentDestination( + onNavigateBack: () -> Unit, +) { + composable( + route = ENVIRONMENT_ROUTE, + enterTransition = TransitionProviders.Enter.slideUp, + exitTransition = TransitionProviders.Exit.slideDown, + popEnterTransition = TransitionProviders.Enter.slideUp, + popExitTransition = TransitionProviders.Exit.slideDown, + ) { + EnvironmentScreen(onNavigateBack = onNavigateBack) + } +} + +/** + * Navigate to the about screen. + */ +fun NavController.navigateToEnvironment(navOptions: NavOptions? = null) { + navigate(ENVIRONMENT_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt new file mode 100644 index 000000000..e9d407fae --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreen.kt @@ -0,0 +1,201 @@ +package com.x8bit.bitwarden.ui.auth.feature.environment + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField +import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar + +/** + * Displays the about self-hosted/custom environment screen. + */ +@Suppress("LongMethod") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnvironmentScreen( + onNavigateBack: () -> Unit, + viewModel: EnvironmentViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is EnvironmentEvent.NavigateBack -> onNavigateBack.invoke() + is EnvironmentEvent.ShowToast -> { + Toast.makeText(context, event.message(context.resources), Toast.LENGTH_SHORT).show() + } + } + } + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + BitwardenTopAppBar( + title = stringResource(id = R.string.settings), + scrollBehavior = scrollBehavior, + navigationIcon = painterResource(id = R.drawable.ic_close), + navigationIconContentDescription = stringResource(id = R.string.close), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(EnvironmentAction.CloseClick) } + }, + actions = { + BitwardenTextButton( + label = stringResource(id = R.string.save), + onClick = remember(viewModel) { + { viewModel.trySendAction(EnvironmentAction.SaveClick) } + }, + ) + }, + ) + }, + ) { innerPadding -> + Column( + Modifier + .padding(innerPadding) + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()), + ) { + BitwardenListHeaderText( + label = stringResource(id = R.string.self_hosted_environment), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenTextField( + label = stringResource(id = R.string.server_url), + value = state.serverUrl, + placeholder = "ex. https://bitwarden.company.com", + onValueChange = remember(viewModel) { + { viewModel.trySendAction(EnvironmentAction.ServerUrlChange(it)) } + }, + keyboardType = KeyboardType.Uri, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(id = R.string.self_hosted_environment_footer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + + BitwardenListHeaderText( + label = stringResource(id = R.string.custom_environment), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenTextField( + label = stringResource(id = R.string.web_vault_url), + value = state.webVaultServerUrl, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(EnvironmentAction.WebVaultServerUrlChange(it)) } + }, + keyboardType = KeyboardType.Uri, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenTextField( + label = stringResource(id = R.string.api_url), + value = state.apiServerUrl, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(EnvironmentAction.ApiServerUrlChange(it)) } + }, + keyboardType = KeyboardType.Uri, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenTextField( + label = stringResource(id = R.string.identity_url), + value = state.identityServerUrl, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(EnvironmentAction.IdentityServerUrlChange(it)) } + }, + keyboardType = KeyboardType.Uri, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + BitwardenTextField( + label = stringResource(id = R.string.icons_url), + value = state.iconsServerUrl, + onValueChange = remember(viewModel) { + { viewModel.trySendAction(EnvironmentAction.IconsServerUrlChange(it)) } + }, + keyboardType = KeyboardType.Uri, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = stringResource(id = R.string.custom_environment_footer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp), + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt new file mode 100644 index 000000000..c9e78f0d0 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModel.kt @@ -0,0 +1,181 @@ +package com.x8bit.bitwarden.ui.auth.feature.environment + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +/** + * View model for the self-hosted/custom environment screen. + */ +@HiltViewModel +class EnvironmentViewModel @Inject constructor( + private val savedStateHandle: SavedStateHandle, +) : BaseViewModel( + // TODO: Pull non-saved state from EnvironmentRepository (BIT-817) + initialState = savedStateHandle[KEY_STATE] + ?: EnvironmentState( + serverUrl = "", + webVaultServerUrl = "", + apiServerUrl = "", + identityServerUrl = "", + iconsServerUrl = "", + ), +) { + + init { + stateFlow + .onEach { + savedStateHandle[KEY_STATE] = it + } + .launchIn(viewModelScope) + } + + override fun handleAction(action: EnvironmentAction): Unit = when (action) { + is EnvironmentAction.CloseClick -> handleCloseClickAction() + is EnvironmentAction.SaveClick -> handleSaveClickAction() + is EnvironmentAction.ServerUrlChange -> handleServerUrlChangeAction(action) + is EnvironmentAction.WebVaultServerUrlChange -> handleWebVaultServerUrlChangeAction(action) + is EnvironmentAction.ApiServerUrlChange -> handleApiServerUrlChangeAction(action) + is EnvironmentAction.IdentityServerUrlChange -> handleIdentityServerUrlChangeAction(action) + is EnvironmentAction.IconsServerUrlChange -> handleIconsServerUrlChangeAction(action) + } + + private fun handleCloseClickAction() { + sendEvent(EnvironmentEvent.NavigateBack) + } + + private fun handleSaveClickAction() { + // TODO: Save custom value (BIT-817) + sendEvent(EnvironmentEvent.ShowToast("Not yet implemented.".asText())) + } + + private fun handleServerUrlChangeAction( + action: EnvironmentAction.ServerUrlChange, + ) { + mutableStateFlow.update { + it.copy(serverUrl = action.serverUrl) + } + } + + private fun handleWebVaultServerUrlChangeAction( + action: EnvironmentAction.WebVaultServerUrlChange, + ) { + mutableStateFlow.update { + it.copy(webVaultServerUrl = action.webVaultServerUrl) + } + } + + private fun handleApiServerUrlChangeAction( + action: EnvironmentAction.ApiServerUrlChange, + ) { + mutableStateFlow.update { + it.copy(apiServerUrl = action.apiServerUrl) + } + } + + private fun handleIdentityServerUrlChangeAction( + action: EnvironmentAction.IdentityServerUrlChange, + ) { + mutableStateFlow.update { + it.copy(identityServerUrl = action.identityServerUrl) + } + } + + private fun handleIconsServerUrlChangeAction( + action: EnvironmentAction.IconsServerUrlChange, + ) { + mutableStateFlow.update { + it.copy(iconsServerUrl = action.iconsServerUrl) + } + } +} + +/** + * Models the state of the environment screen. + */ +@Parcelize +data class EnvironmentState( + val serverUrl: String, + val webVaultServerUrl: String, + val apiServerUrl: String, + val identityServerUrl: String, + val iconsServerUrl: String, +) : Parcelable + +/** + * Models events for the environment screen. + */ +sealed class EnvironmentEvent { + /** + * Navigate back. + */ + data object NavigateBack : EnvironmentEvent() + + /** + * Show a toast with the given message. + */ + data class ShowToast( + val message: Text, + ) : EnvironmentEvent() +} + +/** + * Models actions for the environment screen. + */ +sealed class EnvironmentAction { + /** + * User clicked back button. + */ + data object CloseClick : EnvironmentAction() + + /** + * User clicked the save button. + */ + data object SaveClick : EnvironmentAction() + + /** + * Indicates that the overall server URL has changed. + */ + data class ServerUrlChange( + val serverUrl: String, + ) : EnvironmentAction() + + /** + * Indicates that the web vault server URL has changed. + */ + data class WebVaultServerUrlChange( + val webVaultServerUrl: String, + ) : EnvironmentAction() + + /** + * Indicates that the API server URL has changed. + */ + data class ApiServerUrlChange( + val apiServerUrl: String, + ) : EnvironmentAction() + + /** + * Indicates that the identity server URL has changed. + */ + data class IdentityServerUrlChange( + val identityServerUrl: String, + ) : EnvironmentAction() + + /** + * Indicates that the icons server URL has changed. + */ + data class IconsServerUrlChange( + val iconsServerUrl: String, + ) : EnvironmentAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt index 652b8242f..d7ff0fdea 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingNavigation.kt @@ -21,6 +21,7 @@ fun NavController.navigateToLanding(navOptions: NavOptions? = null) { fun NavGraphBuilder.landingDestinations( onNavigateToCreateAccount: () -> Unit, onNavigateToLogin: (emailAddress: String) -> Unit, + onNavigateToEnvironment: () -> Unit, ) { composable( route = LANDING_ROUTE, @@ -32,6 +33,7 @@ fun NavGraphBuilder.landingDestinations( LandingScreen( onNavigateToCreateAccount = onNavigateToCreateAccount, onNavigateToLogin = onNavigateToLogin, + onNavigateToEnvironment = onNavigateToEnvironment, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt index 2a3638c3c..317f9e24f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt @@ -60,6 +60,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField fun LandingScreen( onNavigateToCreateAccount: () -> Unit, onNavigateToLogin: (emailAddress: String) -> Unit, + onNavigateToEnvironment: () -> Unit, viewModel: LandingViewModel = hiltViewModel(), ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() @@ -69,6 +70,8 @@ fun LandingScreen( is LandingEvent.NavigateToLogin -> onNavigateToLogin( event.emailAddress, ) + + LandingEvent.NavigateToEnvironment -> onNavigateToEnvironment() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt index 7a7752c71..68004f6c2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt @@ -113,10 +113,9 @@ class LandingViewModel @Inject constructor( Environment.Type.US -> Environment.Us Environment.Type.EU -> Environment.Eu Environment.Type.SELF_HOSTED -> { - // TODO Show dialog for setting selected environment (BIT-330) - Environment.SelfHosted( - environmentUrlData = Environment.Us.environmentUrlData, - ) + // Launch the self-hosted screen and select the full environment details there. + sendEvent(LandingEvent.NavigateToEnvironment) + return } } @@ -155,6 +154,11 @@ sealed class LandingEvent { data class NavigateToLogin( val emailAddress: String, ) : LandingEvent() + + /** + * Navigates to the self-hosted/custom environment screen. + */ + data object NavigateToEnvironment : LandingEvent() } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt index 312af719f..d8bf900e8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenTextField.kt @@ -15,6 +15,10 @@ import androidx.compose.ui.tooling.preview.Preview * @param value current next on the text field. * @param modifier modifier for the composable. * @param onValueChange callback that is triggered when the input of the text field changes. + * @param placeholder the optional placeholder to be displayed when the text field is in focus and + * the [value] is empty. + * @param readOnly `true` if the input should be read-only and not accept user interactions. + * @param keyboardType the preferred type of keyboard input. */ @Composable fun BitwardenTextField( @@ -22,6 +26,7 @@ fun BitwardenTextField( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, + placeholder: String? = null, readOnly: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, ) { @@ -29,6 +34,9 @@ fun BitwardenTextField( modifier = modifier, label = { Text(text = label) }, value = value, + placeholder = placeholder?.let { + { Text(text = it) } + }, onValueChange = onValueChange, singleLine = true, readOnly = readOnly, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt new file mode 100644 index 000000000..032e180f5 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentScreenTest.kt @@ -0,0 +1,200 @@ +package com.x8bit.bitwarden.ui.auth.feature.environment + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class EnvironmentScreenTest : BaseComposeTest() { + private var onNavigateBackCalled = false + private val mutableEventFlow = MutableSharedFlow( + extraBufferCapacity = Int.MAX_VALUE, + ) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + EnvironmentScreen( + onNavigateBack = { onNavigateBackCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `NavigateBack event should invoke onNavigateBack`() { + mutableEventFlow.tryEmit(EnvironmentEvent.NavigateBack) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `close click should send CloseClick`() { + composeTestRule.onNodeWithContentDescription("Close").performClick() + verify { + viewModel.trySendAction(EnvironmentAction.CloseClick) + } + } + + @Test + fun `save click should send SaveClick`() { + composeTestRule.onNodeWithText("Save").performClick() + verify { + viewModel.trySendAction(EnvironmentAction.SaveClick) + } + } + + @Test + fun `server URL should change according to the state`() { + composeTestRule + .onNodeWithText("Server URL") + // Click to focus to see placeholder + .performClick() + .assertTextEquals("Server URL", "ex. https://bitwarden.company.com", "") + + mutableStateFlow.update { it.copy(serverUrl = "server-url") } + + composeTestRule + .onNodeWithText("Server URL") + .assertTextEquals("Server URL", "server-url") + } + + @Test + fun `server URL change should send ServerUrlChange`() { + composeTestRule.onNodeWithText("Server URL").performTextInput("updated-server-url") + verify { + viewModel.trySendAction( + EnvironmentAction.ServerUrlChange(serverUrl = "updated-server-url"), + ) + } + } + + @Test + fun `web vault URL should change according to the state`() { + composeTestRule + .onNodeWithText("Web vault server URL") + .assertTextEquals("Web vault server URL", "") + + mutableStateFlow.update { it.copy(webVaultServerUrl = "web-vault-url") } + + composeTestRule + .onNodeWithText("Web vault server URL") + .assertTextEquals("Web vault server URL", "web-vault-url") + } + + @Test + fun `web vault server URL change should send WebVaultServerUrlChange`() { + composeTestRule + .onNodeWithText("Web vault server URL") + .performTextInput("updated-web-vault-url") + verify { + viewModel.trySendAction( + EnvironmentAction.WebVaultServerUrlChange( + webVaultServerUrl = "updated-web-vault-url", + ), + ) + } + } + + @Test + fun `API server URL should change according to the state`() { + composeTestRule + .onNodeWithText("API server URL") + .assertTextEquals("API server URL", "") + + mutableStateFlow.update { it.copy(apiServerUrl = "api-url") } + + composeTestRule + .onNodeWithText("API server URL") + .assertTextEquals("API server URL", "api-url") + } + + @Test + fun `API server URL change should send ApiServerUrlChange`() { + composeTestRule + .onNodeWithText("API server URL") + .performTextInput("updated-api-url") + verify { + viewModel.trySendAction( + EnvironmentAction.ApiServerUrlChange(apiServerUrl = "updated-api-url"), + ) + } + } + + @Test + fun `identity server URL should change according to the state`() { + composeTestRule + .onNodeWithText("Identity server URL") + .assertTextEquals("Identity server URL", "") + + mutableStateFlow.update { it.copy(identityServerUrl = "identity-url") } + + composeTestRule + .onNodeWithText("Identity server URL") + .assertTextEquals("Identity server URL", "identity-url") + } + + @Test + fun `identity server URL change should send IdentityServerUrlChange`() { + composeTestRule + .onNodeWithText("Identity server URL") + .performTextInput("updated-identity-url") + verify { + viewModel.trySendAction( + EnvironmentAction.IdentityServerUrlChange( + identityServerUrl = "updated-identity-url", + ), + ) + } + } + + @Test + fun `icons server URL should change according to the state`() { + composeTestRule + .onNodeWithText("Icons server URL") + .assertTextEquals("Icons server URL", "") + + mutableStateFlow.update { it.copy(iconsServerUrl = "icons-url") } + + composeTestRule + .onNodeWithText("Icons server URL") + .assertTextEquals("Icons server URL", "icons-url") + } + + @Test + fun `icons server URL change should send IconsServerUrlChange`() { + composeTestRule + .onNodeWithText("Icons server URL") + .performTextInput("updated-icons-url") + verify { + viewModel.trySendAction( + EnvironmentAction.IconsServerUrlChange(iconsServerUrl = "updated-icons-url"), + ) + } + } + + companion object { + val DEFAULT_STATE = EnvironmentState( + serverUrl = "", + webVaultServerUrl = "", + apiServerUrl = "", + identityServerUrl = "", + iconsServerUrl = "", + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt new file mode 100644 index 000000000..b748921ba --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/environment/EnvironmentViewModelTest.kt @@ -0,0 +1,151 @@ +package com.x8bit.bitwarden.ui.auth.feature.environment + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class EnvironmentViewModelTest : BaseViewModelTest() { + + @Test + fun `initial state should be correct when there is no saved state`() { + val viewModel = createViewModel() + assertEquals( + DEFAULT_STATE, + viewModel.stateFlow.value, + ) + } + + @Test + fun `initial state should be correct when restoring from the save state handle`() { + val savedState = EnvironmentState( + serverUrl = "saved-server", + webVaultServerUrl = "saved-web-vault", + apiServerUrl = "saved-api", + identityServerUrl = "saved-identity", + iconsServerUrl = "saved-icons", + ) + val viewModel = createViewModel( + savedStateHandle = SavedStateHandle( + initialState = mapOf( + "state" to savedState, + ), + ), + ) + assertEquals( + EnvironmentState( + serverUrl = "saved-server", + webVaultServerUrl = "saved-web-vault", + apiServerUrl = "saved-api", + identityServerUrl = "saved-identity", + iconsServerUrl = "saved-icons", + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `CloseClick should emit NavigateBack`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(EnvironmentAction.CloseClick) + assertEquals(EnvironmentEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `SaveClick should emit ShowTest`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(EnvironmentAction.SaveClick) + assertEquals( + EnvironmentEvent.ShowToast("Not yet implemented.".asText()), + awaitItem(), + ) + } + } + + @Test + fun `ServerUrlChange should update the server URL`() { + val viewModel = createViewModel() + viewModel.actionChannel.trySend( + EnvironmentAction.ServerUrlChange(serverUrl = "updated-server-url"), + ) + assertEquals( + DEFAULT_STATE.copy(serverUrl = "updated-server-url"), + viewModel.stateFlow.value, + ) + } + + @Test + fun `WebVaultServerUrlChange should update the web vault server URL`() { + val viewModel = createViewModel() + viewModel.actionChannel.trySend( + EnvironmentAction.WebVaultServerUrlChange(webVaultServerUrl = "updated-web-vault-url"), + ) + assertEquals( + DEFAULT_STATE.copy(webVaultServerUrl = "updated-web-vault-url"), + viewModel.stateFlow.value, + ) + } + + @Test + fun `ApiServerUrlChange should update the API server URL`() { + val viewModel = createViewModel() + viewModel.actionChannel.trySend( + EnvironmentAction.ApiServerUrlChange(apiServerUrl = "updated-api-url"), + ) + assertEquals( + DEFAULT_STATE.copy(apiServerUrl = "updated-api-url"), + viewModel.stateFlow.value, + ) + } + + @Test + fun `IdentityServerUrlChange should update the identity server URL`() { + val viewModel = createViewModel() + viewModel.actionChannel.trySend( + EnvironmentAction.IdentityServerUrlChange(identityServerUrl = "updated-identity-url"), + ) + assertEquals( + DEFAULT_STATE.copy(identityServerUrl = "updated-identity-url"), + viewModel.stateFlow.value, + ) + } + + @Test + fun `IconsServerUrlChange should update the icons server URL`() { + val viewModel = createViewModel() + viewModel.actionChannel.trySend( + EnvironmentAction.IconsServerUrlChange(iconsServerUrl = "updated-icons-url"), + ) + assertEquals( + DEFAULT_STATE.copy(iconsServerUrl = "updated-icons-url"), + viewModel.stateFlow.value, + ) + } + + //region Helper methods + + private fun createViewModel( + savedStateHandle: SavedStateHandle = SavedStateHandle(), + ): EnvironmentViewModel = + EnvironmentViewModel( + savedStateHandle = savedStateHandle, + ) + + //endregion Helper methods + + companion object { + private val DEFAULT_STATE = EnvironmentState( + serverUrl = "", + webVaultServerUrl = "", + apiServerUrl = "", + identityServerUrl = "", + iconsServerUrl = "", + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt index e9e735573..728cd389f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt @@ -47,6 +47,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -67,6 +68,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -87,6 +89,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -107,6 +110,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -128,6 +132,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -148,6 +153,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -174,6 +180,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -194,6 +201,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = { onNavigateToCreateAccountCalled = true }, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -217,6 +225,7 @@ class LandingScreenTest : BaseComposeTest() { onNavigateToLogin = { email -> capturedEmail = email }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -224,6 +233,24 @@ class LandingScreenTest : BaseComposeTest() { assertEquals(testEmail, capturedEmail) } + @Test + fun `NavigateToEnvironment event should call onNavigateToEvent`() { + var onNavigateToEnvironmentCalled = false + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns flowOf(LandingEvent.NavigateToEnvironment) + every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) + } + composeTestRule.setContent { + LandingScreen( + onNavigateToCreateAccount = { }, + onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = { onNavigateToEnvironmentCalled = true }, + viewModel = viewModel, + ) + } + assertTrue(onNavigateToEnvironmentCalled) + } + @Test fun `selecting environment should send EnvironmentOptionSelect action`() { val selectedEnvironment = Environment.Eu @@ -236,6 +263,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -272,6 +300,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) } @@ -321,6 +350,7 @@ class LandingScreenTest : BaseComposeTest() { LandingScreen( onNavigateToCreateAccount = {}, onNavigateToLogin = { _ -> }, + onNavigateToEnvironment = {}, viewModel = viewModel, ) }