BIT-725: Replace "region" concept with Environment (#152)

This commit is contained in:
Brian Yencho 2023-10-24 11:17:10 -05:00 committed by Álison Fernandes
parent e4ab70a106
commit 2472648434
10 changed files with 120 additions and 75 deletions

View file

@ -27,12 +27,6 @@ interface AuthRepository {
*/
var rememberedEmailAddress: String?
/**
* The currently selected region label (`null` if not set).
*/
// TODO replace this with a more robust selected region object BIT-725
var selectedRegionLabel: String
/**
* Attempt to login with the given email and password. Updated access token will be reflected
* in [authStateFlow].

View file

@ -77,9 +77,6 @@ class AuthRepositoryImpl @Inject constructor(
authDiskSource.rememberedEmailAddress = value
}
// TODO Handle selected region functionality BIT-725
override var selectedRegionLabel: String = "bitwarden.us"
override suspend fun login(
email: String,
password: String,

View file

@ -40,6 +40,7 @@ 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.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
@ -132,10 +133,10 @@ fun LandingScreen(
Spacer(modifier = Modifier.height(10.dp))
RegionSelector(
selectedOption = state.selectedRegion,
EnvironmentSelector(
selectedOption = state.selectedEnvironment.type,
onOptionSelected = remember(viewModel) {
{ viewModel.trySendAction(LandingAction.RegionOptionSelect(it)) }
{ viewModel.trySendAction(LandingAction.EnvironmentTypeSelect(it)) }
},
modifier = Modifier
.semantics { testTag = "RegionSelectorDropdown" }
@ -208,19 +209,19 @@ fun LandingScreen(
* from a list of options. When an option is selected, it invokes the provided callback
* and displays the currently selected region on the UI.
*
* @param selectedOption The currently selected region option.
* @param onOptionSelected A callback that gets invoked when a region option is selected
* @param selectedOption The currently selected environment option.
* @param onOptionSelected A callback that gets invoked when an environment option is selected
* and passes the selected option as an argument.
* @param modifier A [Modifier] for the composable.
*
*/
@Composable
private fun RegionSelector(
selectedOption: LandingState.RegionOption,
onOptionSelected: (LandingState.RegionOption) -> Unit,
private fun EnvironmentSelector(
selectedOption: Environment.Type,
onOptionSelected: (Environment.Type) -> Unit,
modifier: Modifier,
) {
val options = LandingState.RegionOption.values().toList()
val options = Environment.Type.values()
var expanded by remember { mutableStateOf(false) }
Box(modifier = modifier) {
@ -238,7 +239,7 @@ private fun RegionSelector(
modifier = Modifier.padding(end = 12.dp),
)
Text(
text = selectedOption.label,
text = selectedOption.label(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp),
@ -256,7 +257,7 @@ private fun RegionSelector(
) {
options.forEach { optionString ->
DropdownMenuItem(
text = { Text(text = optionString.label) },
text = { Text(text = optionString.label()) },
onClick = {
expanded = false
onOptionSelected(optionString)

View file

@ -5,6 +5,8 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail
@ -24,6 +26,7 @@ private const val KEY_STATE = "state"
@HiltViewModel
class LandingViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LandingState, LandingEvent, LandingAction>(
initialState = savedStateHandle[KEY_STATE]
@ -31,15 +34,20 @@ class LandingViewModel @Inject constructor(
emailInput = authRepository.rememberedEmailAddress.orEmpty(),
isContinueButtonEnabled = authRepository.rememberedEmailAddress != null,
isRememberMeEnabled = authRepository.rememberedEmailAddress != null,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
selectedEnvironment = environmentRepository.environment,
errorDialogState = BasicDialogState.Hidden,
),
) {
init {
// As state updates, write to saved state handle:
// As state updates:
// - write to saved state handle
// - updated selected environment
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.onEach {
savedStateHandle[KEY_STATE] = it
environmentRepository.environment = it.selectedEnvironment
}
.launchIn(viewModelScope)
}
@ -50,7 +58,7 @@ class LandingViewModel @Inject constructor(
is LandingAction.ErrorDialogDismiss -> handleErrorDialogDismiss()
is LandingAction.RememberMeToggle -> handleRememberMeToggled(action)
is LandingAction.EmailInputChanged -> handleEmailInputUpdated(action)
is LandingAction.RegionOptionSelect -> handleRegionSelect(action)
is LandingAction.EnvironmentTypeSelect -> handleEnvironmentTypeSelect(action)
}
}
@ -82,8 +90,6 @@ class LandingViewModel @Inject constructor(
// Update the remembered email address
authRepository.rememberedEmailAddress = email.takeUnless { !isRememberMeEnabled }
// Update the selected region selectedRegionLabel
authRepository.selectedRegionLabel = mutableStateFlow.value.selectedRegion.label
sendEvent(LandingEvent.NavigateToLogin(email))
}
@ -102,10 +108,21 @@ class LandingViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isRememberMeEnabled = action.isChecked) }
}
private fun handleRegionSelect(action: LandingAction.RegionOptionSelect) {
private fun handleEnvironmentTypeSelect(action: LandingAction.EnvironmentTypeSelect) {
val environment = when (action.environmentType) {
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,
)
}
}
mutableStateFlow.update {
it.copy(
selectedRegion = action.regionOption,
selectedEnvironment = environment,
)
}
}
@ -119,18 +136,9 @@ data class LandingState(
val emailInput: String,
val isContinueButtonEnabled: Boolean,
val isRememberMeEnabled: Boolean,
val selectedRegion: RegionOption,
val selectedEnvironment: Environment,
val errorDialogState: BasicDialogState,
) : Parcelable {
/**
* Enumerates the possible region options with their corresponding labels.
*/
enum class RegionOption(val label: String) {
BITWARDEN_US("bitwarden.com"),
BITWARDEN_EU("bitwarden.eu"),
SELF_HOSTED("Self-hosted"),
}
}
) : Parcelable
/**
* Models events for the landing screen.
@ -185,7 +193,7 @@ sealed class LandingAction {
/**
* Indicates that the selection from the region drop down has changed.
*/
data class RegionOptionSelect(
val regionOption: LandingState.RegionOption,
data class EnvironmentTypeSelect(
val environmentType: Environment.Type,
) : LandingAction()
}

View file

@ -147,12 +147,12 @@ fun LoginScreen(
.padding(bottom = 24.dp),
isEnabled = state.isLoginButtonEnabled,
)
// TODO Get the "login target" from a dropdown (BIT-202)
Text(
text = stringResource(
id = R.string.logging_in_as_x_on_y,
state.emailAddress,
state.region,
state.environmentLabel(),
),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium,

View file

@ -11,7 +11,9 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
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 com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
@ -31,6 +33,7 @@ private const val KEY_STATE = "state"
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val environmentRepository: EnvironmentRepository,
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginState, LoginEvent, LoginAction>(
initialState = savedStateHandle[KEY_STATE]
@ -38,7 +41,7 @@ class LoginViewModel @Inject constructor(
emailAddress = LoginArgs(savedStateHandle).emailAddress,
isLoginButtonEnabled = true,
passwordInput = "",
region = authRepository.selectedRegionLabel,
environmentLabel = environmentRepository.environment.label,
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
captchaToken = LoginArgs(savedStateHandle).captchaToken,
@ -193,7 +196,7 @@ data class LoginState(
val passwordInput: String,
val emailAddress: String,
val captchaToken: String?,
val region: String,
val environmentLabel: Text,
val isLoginButtonEnabled: Boolean,
val loadingDialogState: LoadingDialogState,
val errorDialogState: BasicDialogState,

View file

@ -1,5 +1,6 @@
package com.x8bit.bitwarden.ui.auth.feature.landing
import android.app.Application
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
@ -15,6 +16,8 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
@ -29,6 +32,9 @@ import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
class LandingScreenTest : BaseComposeTest() {
private val resources
get() = ApplicationProvider.getApplicationContext<Application>().resources
@Test
fun `continue button should be enabled or disabled according to the state`() {
val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
@ -218,8 +224,8 @@ class LandingScreenTest : BaseComposeTest() {
}
@Test
fun `selecting region should send RegionOptionSelect action`() {
val selectedRegion = LandingState.RegionOption.BITWARDEN_EU
fun `selecting environment should send EnvironmentOptionSelect action`() {
val selectedEnvironment = Environment.Eu
val viewModel = mockk<LandingViewModel>(relaxed = true) {
every { eventFlow } returns emptyFlow()
every { stateFlow } returns MutableStateFlow(DEFAULT_STATE)
@ -234,13 +240,17 @@ class LandingScreenTest : BaseComposeTest() {
}
// Clicking to open dropdown
composeTestRule.onNodeWithText(LandingState.RegionOption.BITWARDEN_US.label).performClick()
composeTestRule
.onNodeWithText(Environment.Us.label.toString(resources))
.performClick()
// Clicking item from the dropdown menu
composeTestRule.onNodeWithText(selectedRegion.label).performClick()
composeTestRule
.onNodeWithText(selectedEnvironment.label.toString(resources))
.performClick()
verify {
viewModel.trySendAction(LandingAction.RegionOptionSelect(selectedRegion))
viewModel.trySendAction(LandingAction.EnvironmentTypeSelect(selectedEnvironment.type))
}
}
@ -319,7 +329,7 @@ class LandingScreenTest : BaseComposeTest() {
emailInput = "",
isContinueButtonEnabled = true,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
selectedEnvironment = Environment.Us,
errorDialogState = BasicDialogState.Hidden,
)
}

View file

@ -3,6 +3,7 @@ package com.x8bit.bitwarden.ui.auth.feature.landing
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
@ -145,14 +146,14 @@ class LandingViewModelTest : BaseViewModelTest() {
}
@Test
fun `RegionOptionSelect should update value of selected region`() = runTest {
val inputRegion = LandingState.RegionOption.BITWARDEN_EU
fun `EnvironmentTypeSelect should update value of selected region`() = runTest {
val inputEnvironment = Environment.Eu
val viewModel = createViewModel()
viewModel.stateFlow.test {
awaitItem()
viewModel.trySendAction(LandingAction.RegionOptionSelect(inputRegion))
viewModel.trySendAction(LandingAction.EnvironmentTypeSelect(inputEnvironment.type))
assertEquals(
DEFAULT_STATE.copy(selectedRegion = LandingState.RegionOption.BITWARDEN_EU),
DEFAULT_STATE.copy(selectedEnvironment = Environment.Eu),
awaitItem(),
)
}
@ -162,11 +163,15 @@ class LandingViewModelTest : BaseViewModelTest() {
private fun createViewModel(
rememberedEmail: String? = null,
environment: Environment = Environment.Us,
savedStateHandle: SavedStateHandle = SavedStateHandle(),
): LandingViewModel = LandingViewModel(
authRepository = mockk(relaxed = true) {
every { rememberedEmailAddress } returns rememberedEmail
},
environmentRepository = mockk(relaxed = true) {
every { this@mockk.environment } returns environment
},
savedStateHandle = savedStateHandle,
)
@ -177,7 +182,7 @@ class LandingViewModelTest : BaseViewModelTest() {
emailInput = "",
isContinueButtonEnabled = false,
isRememberMeEnabled = false,
selectedRegion = LandingState.RegionOption.BITWARDEN_US,
selectedEnvironment = Environment.Us,
errorDialogState = BasicDialogState.Hidden,
)
}

View file

@ -14,6 +14,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import io.mockk.every
@ -37,7 +38,7 @@ class LoginScreenTest : BaseComposeTest() {
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
environmentLabel = "".asText(),
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
),
@ -65,7 +66,7 @@ class LoginScreenTest : BaseComposeTest() {
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
environmentLabel = "".asText(),
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
),
@ -93,7 +94,7 @@ class LoginScreenTest : BaseComposeTest() {
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
environmentLabel = "".asText(),
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
),
@ -121,7 +122,7 @@ class LoginScreenTest : BaseComposeTest() {
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
environmentLabel = "".asText(),
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
),
@ -161,7 +162,7 @@ class LoginScreenTest : BaseComposeTest() {
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
environmentLabel = "".asText(),
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
),
@ -190,7 +191,7 @@ class LoginScreenTest : BaseComposeTest() {
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
environmentLabel = "".asText(),
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
),
@ -219,7 +220,7 @@ class LoginScreenTest : BaseComposeTest() {
captchaToken = null,
isLoginButtonEnabled = false,
passwordInput = "",
region = "",
environmentLabel = "".asText(),
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
),

View file

@ -8,6 +8,8 @@ import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
@ -29,7 +31,6 @@ class LoginViewModelTest : BaseViewModelTest() {
private val savedStateHandle = SavedStateHandle().also {
it["email_address"] = "test@gmail.com"
it["region_label"] = ""
}
@BeforeEach
@ -47,7 +48,9 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = savedStateHandle,
)
@ -72,6 +75,9 @@ class LoginViewModelTest : BaseViewModelTest() {
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
},
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = handle,
)
viewModel.stateFlow.test {
@ -84,7 +90,9 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = savedStateHandle,
)
@ -108,10 +116,13 @@ class LoginViewModelTest : BaseViewModelTest() {
)
} returns LoginResult.Error(errorMessage = "mock_error")
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
}
val environmentRepository = mockk<EnvironmentRepository> {
every { environment } returns Environment.Us
}
val viewModel = LoginViewModel(
authRepository = authRepository,
environmentRepository = environmentRepository,
savedStateHandle = savedStateHandle,
)
viewModel.stateFlow.test {
@ -148,10 +159,12 @@ class LoginViewModelTest : BaseViewModelTest() {
login("test@gmail.com", "", captchaToken = null)
} returns LoginResult.Success
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
}
val viewModel = LoginViewModel(
authRepository = authRepository,
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = savedStateHandle,
)
viewModel.stateFlow.test {
@ -186,10 +199,12 @@ class LoginViewModelTest : BaseViewModelTest() {
coEvery { login("test@gmail.com", "", captchaToken = null) } returns
LoginResult.CaptchaRequired(captchaId = "mock_captcha_id")
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
}
val viewModel = LoginViewModel(
authRepository = authRepository,
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = savedStateHandle,
)
viewModel.eventFlow.test {
@ -207,7 +222,9 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = savedStateHandle,
)
@ -226,7 +243,9 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = savedStateHandle,
)
@ -245,7 +264,9 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = savedStateHandle,
)
@ -264,7 +285,9 @@ class LoginViewModelTest : BaseViewModelTest() {
val viewModel = LoginViewModel(
authRepository = mockk {
every { captchaTokenResultFlow } returns flowOf()
every { selectedRegionLabel } returns "bitwarden.us"
},
environmentRepository = mockk {
every { environment } returns Environment.Us
},
savedStateHandle = savedStateHandle,
)
@ -283,7 +306,6 @@ class LoginViewModelTest : BaseViewModelTest() {
every { captchaTokenResultFlow } returns flowOf(
CaptchaCallbackTokenResult.Success("token"),
)
every { selectedRegionLabel } returns "bitwarden.us"
coEvery {
login(
"test@gmail.com",
@ -292,8 +314,12 @@ class LoginViewModelTest : BaseViewModelTest() {
)
} returns LoginResult.Success
}
val environmentRepository = mockk<EnvironmentRepository> {
every { environment } returns Environment.Us
}
LoginViewModel(
authRepository = authRepository,
environmentRepository = environmentRepository,
savedStateHandle = savedStateHandle,
)
coVerify {
@ -306,7 +332,7 @@ class LoginViewModelTest : BaseViewModelTest() {
emailAddress = "test@gmail.com",
passwordInput = "",
isLoginButtonEnabled = true,
region = "bitwarden.us",
environmentLabel = Environment.Us.type.label,
loadingDialogState = LoadingDialogState.Hidden,
errorDialogState = BasicDialogState.Hidden,
captchaToken = null,