mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 10:25:57 +03:00
BIT-330: Implement self-hosting/custom environment UI (#184)
This commit is contained in:
parent
e0f43943fa
commit
967fdc3449
11 changed files with 825 additions and 4 deletions
|
@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<EnvironmentState, EnvironmentEvent, EnvironmentAction>(
|
||||
// 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()
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<EnvironmentEvent>(
|
||||
extraBufferCapacity = Int.MAX_VALUE,
|
||||
)
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<EnvironmentViewModel>(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 = "",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 = "",
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<LandingViewModel>(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,
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue