diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/Environment.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/Environment.kt index b49ed5057..98cd1a90d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/Environment.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/model/Environment.kt @@ -1,18 +1,15 @@ package com.x8bit.bitwarden.data.platform.repository.model -import android.os.Parcelable import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText -import kotlinx.parcelize.Parcelize -import kotlinx.parcelize.RawValue /** * A higher-level wrapper around [EnvironmentUrlDataJson] that provides type-safety, enumerability, * and human-readable labels. */ -sealed class Environment : Parcelable { +sealed class Environment { /** * The [Type] of the environment. */ @@ -31,7 +28,6 @@ sealed class Environment : Parcelable { /** * The default US environment. */ - @Parcelize data object Us : Environment() { override val type: Type get() = Type.US override val environmentUrlData: EnvironmentUrlDataJson @@ -41,7 +37,6 @@ sealed class Environment : Parcelable { /** * The default EU environment. */ - @Parcelize data object Eu : Environment() { override val type: Type get() = Type.EU override val environmentUrlData: EnvironmentUrlDataJson @@ -51,9 +46,8 @@ sealed class Environment : Parcelable { /** * A custom self-hosted environment with a fully configurable [environmentUrlData]. */ - @Parcelize data class SelfHosted( - override val environmentUrlData: @RawValue EnvironmentUrlDataJson, + override val environmentUrlData: EnvironmentUrlDataJson, ) : Environment() { override val type: Type get() = Type.SELF_HOSTED } 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 317f9e24f..cc60ef24e 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 @@ -142,7 +142,7 @@ fun LandingScreen( Spacer(modifier = Modifier.height(10.dp)) EnvironmentSelector( - selectedOption = state.selectedEnvironment.type, + selectedOption = state.selectedEnvironmentType, onOptionSelected = remember(viewModel) { { viewModel.trySendAction(LandingAction.EnvironmentTypeSelect(it)) } }, 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 68004f6c2..ef75ec2d2 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 @@ -34,7 +34,7 @@ class LandingViewModel @Inject constructor( emailInput = authRepository.rememberedEmailAddress.orEmpty(), isContinueButtonEnabled = authRepository.rememberedEmailAddress != null, isRememberMeEnabled = authRepository.rememberedEmailAddress != null, - selectedEnvironment = environmentRepository.environment, + selectedEnvironmentType = environmentRepository.environment.type, errorDialogState = BasicDialogState.Hidden, ), ) { @@ -42,11 +42,19 @@ class LandingViewModel @Inject constructor( init { // As state updates: // - write to saved state handle - // - updated selected environment stateFlow .onEach { savedStateHandle[KEY_STATE] = it - environmentRepository.environment = it.selectedEnvironment + } + .launchIn(viewModelScope) + + // Listen for changes in environment triggered both by this VM and externally. + environmentRepository + .environmentStateFlow + .onEach { environment -> + sendAction( + LandingAction.Internal.UpdatedEnvironmentReceive(environment = environment), + ) } .launchIn(viewModelScope) } @@ -59,6 +67,9 @@ class LandingViewModel @Inject constructor( is LandingAction.RememberMeToggle -> handleRememberMeToggled(action) is LandingAction.EmailInputChanged -> handleEmailInputUpdated(action) is LandingAction.EnvironmentTypeSelect -> handleEnvironmentTypeSelect(action) + is LandingAction.Internal.UpdatedEnvironmentReceive -> { + handleUpdatedEnvironmentReceive(action) + } } } @@ -119,9 +130,17 @@ class LandingViewModel @Inject constructor( } } + // Update the environment in the repo; the VM state will update accordingly because it is + // listening for changes. + environmentRepository.environment = environment + } + + private fun handleUpdatedEnvironmentReceive( + action: LandingAction.Internal.UpdatedEnvironmentReceive, + ) { mutableStateFlow.update { it.copy( - selectedEnvironment = environment, + selectedEnvironmentType = action.environment.type, ) } } @@ -135,7 +154,7 @@ data class LandingState( val emailInput: String, val isContinueButtonEnabled: Boolean, val isRememberMeEnabled: Boolean, - val selectedEnvironment: Environment, + val selectedEnvironmentType: Environment.Type, val errorDialogState: BasicDialogState, ) : Parcelable @@ -200,4 +219,17 @@ sealed class LandingAction { data class EnvironmentTypeSelect( val environmentType: Environment.Type, ) : LandingAction() + + /** + * Actions for internal use by the ViewModel. + */ + sealed class Internal : LandingAction() { + + /** + * Indicates that there has been a change in [environment]. + */ + data class UpdatedEnvironmentReceive( + val environment: Environment, + ) : Internal() + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/FakeEnvironmentRepository.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/FakeEnvironmentRepository.kt new file mode 100644 index 000000000..5bb7620bc --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/FakeEnvironmentRepository.kt @@ -0,0 +1,22 @@ +package com.x8bit.bitwarden.data.platform.repository.util + +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository +import com.x8bit.bitwarden.data.platform.repository.model.Environment +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A faked implementation of [EnvironmentRepository] based on in-memory caching. + */ +class FakeEnvironmentRepository : EnvironmentRepository { + override var environment: Environment + get() = mutableEnvironmentStateFlow.value + set(value) { + mutableEnvironmentStateFlow.value = value + } + override val environmentStateFlow: StateFlow + get() = mutableEnvironmentStateFlow.asStateFlow() + + private val mutableEnvironmentStateFlow = MutableStateFlow(Environment.Us) +} 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 728cd389f..57dc3c713 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 @@ -366,7 +366,7 @@ class LandingScreenTest : BaseComposeTest() { emailInput = "", isContinueButtonEnabled = true, isRememberMeEnabled = false, - selectedEnvironment = Environment.Us, + selectedEnvironmentType = Environment.Type.US, errorDialogState = BasicDialogState.Hidden, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt index c07b50a6c..555b541d8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt @@ -4,6 +4,7 @@ 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.data.platform.repository.util.FakeEnvironmentRepository 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 @@ -14,6 +15,9 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class LandingViewModelTest : BaseViewModelTest() { + + private val fakeEnvironmentRepository = FakeEnvironmentRepository() + @Test fun `initial state should be correct when there is no remembered email`() = runTest { val viewModel = createViewModel() @@ -146,14 +150,44 @@ class LandingViewModelTest : BaseViewModelTest() { } @Test - fun `EnvironmentTypeSelect should update value of selected region`() = runTest { - val inputEnvironment = Environment.Eu + fun `external environment updates should update the selected environment type`() = runTest { + val viewModel = createViewModel() + viewModel.stateFlow.test { + assertEquals(DEFAULT_STATE, awaitItem()) + + fakeEnvironmentRepository.environment = Environment.Eu + + assertEquals( + DEFAULT_STATE.copy( + selectedEnvironmentType = Environment.Type.EU, + ), + awaitItem(), + ) + } + } + + @Test + fun `EnvironmentTypeSelect should update value of selected region for US or EU`() = runTest { + val inputEnvironmentType = Environment.Type.EU val viewModel = createViewModel() viewModel.stateFlow.test { awaitItem() - viewModel.trySendAction(LandingAction.EnvironmentTypeSelect(inputEnvironment.type)) + viewModel.trySendAction(LandingAction.EnvironmentTypeSelect(inputEnvironmentType)) assertEquals( - DEFAULT_STATE.copy(selectedEnvironment = Environment.Eu), + DEFAULT_STATE.copy(selectedEnvironmentType = Environment.Type.EU), + awaitItem(), + ) + } + } + + @Test + fun `EnvironmentTypeSelect should emit NavigateToEnvironment for self-hosted`() = runTest { + val inputEnvironmentType = Environment.Type.SELF_HOSTED + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(LandingAction.EnvironmentTypeSelect(inputEnvironmentType)) + assertEquals( + LandingEvent.NavigateToEnvironment, awaitItem(), ) } @@ -163,15 +197,12 @@ 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 - }, + environmentRepository = fakeEnvironmentRepository, savedStateHandle = savedStateHandle, ) @@ -182,7 +213,7 @@ class LandingViewModelTest : BaseViewModelTest() { emailInput = "", isContinueButtonEnabled = false, isRememberMeEnabled = false, - selectedEnvironment = Environment.Us, + selectedEnvironmentType = Environment.Type.US, errorDialogState = BasicDialogState.Hidden, ) }