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 36674521b..6f448d688 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.BitwardenSwitch import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import kotlinx.collections.immutable.toImmutableList /** @@ -87,6 +88,32 @@ fun LandingScreen( } when (val dialog = state.dialog) { + is LandingState.DialogState.AccountAlreadyAdded -> { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.account_already_added), + message = stringResource( + id = R.string.switch_to_already_added_account_confirmation, + ), + confirmButtonText = stringResource(id = R.string.yes), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = remember(viewModel) { + { + viewModel.trySendAction( + LandingAction.ConfirmSwitchToMatchingAccountClick( + account = dialog.accountSummary, + ), + ) + } + }, + onDismissClick = remember(viewModel) { + { viewModel.trySendAction(LandingAction.DialogDismiss) } + }, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(LandingAction.DialogDismiss) } + }, + ) + } + is LandingState.DialogState.Error -> { BitwardenBasicDialog( visibilityState = BasicDialogState.Shown( 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 e72d7783a..50f1acaf2 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 @@ -42,6 +42,17 @@ class LandingViewModel @Inject constructor( ), ) { + /** + * Returns the [AccountSummary] from the current state that matches the current email input, + * of `null` if there is no match. + */ + private val matchingAccountSummary: AccountSummary? + get() { + val currentEmail = state.emailInput + val accountSummaries = state.accountSummaries + return accountSummaries.find { it.email == currentEmail } + } + init { // As state updates: // - write to saved state handle @@ -65,6 +76,10 @@ class LandingViewModel @Inject constructor( override fun handleAction(action: LandingAction) { when (action) { is LandingAction.SwitchAccountClick -> handleSwitchAccountClicked(action) + is LandingAction.ConfirmSwitchToMatchingAccountClick -> { + handleConfirmSwitchToMatchingAccountClicked(action) + } + is LandingAction.ContinueButtonClick -> handleContinueButtonClicked() LandingAction.CreateAccountClick -> handleCreateAccountClicked() is LandingAction.DialogDismiss -> handleDialogDismiss() @@ -81,6 +96,12 @@ class LandingViewModel @Inject constructor( authRepository.switchAccount(userId = action.account.userId) } + private fun handleConfirmSwitchToMatchingAccountClicked( + action: LandingAction.ConfirmSwitchToMatchingAccountClick, + ) { + authRepository.switchAccount(userId = action.account.userId) + } + private fun handleEmailInputUpdated(action: LandingAction.EmailInputChanged) { val email = action.input mutableStateFlow.update { @@ -103,6 +124,17 @@ class LandingViewModel @Inject constructor( return } + matchingAccountSummary?.let { accountSummary -> + mutableStateFlow.update { + it.copy( + dialog = LandingState.DialogState.AccountAlreadyAdded( + accountSummary = accountSummary, + ), + ) + } + return + } + val email = mutableStateFlow.value.emailInput val isRememberMeEnabled = mutableStateFlow.value.isRememberMeEnabled @@ -170,6 +202,15 @@ data class LandingState( */ sealed class DialogState : Parcelable { + /** + * Represents a dialog indicating that the current email matches the existing + * [accountSummary]. + */ + @Parcelize + data class AccountAlreadyAdded( + val accountSummary: AccountSummary, + ) : DialogState() + /** * Represents an error dialog with the given [message]. */ @@ -213,6 +254,13 @@ sealed class LandingAction { val account: AccountSummary, ) : LandingAction() + /** + * Indicates the user has confirmed they would like to switch to the existing [account]. + */ + data class ConfirmSwitchToMatchingAccountClick( + val account: AccountSummary, + ) : LandingAction() + /** * Indicates that the continue button has been clicked and the app should navigate to Login. */ 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 cbbc6d646..4bf128114 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 @@ -273,6 +273,80 @@ class LandingScreenTest : BaseComposeTest() { .performClick() verify { viewModel.trySendAction(LandingAction.DialogDismiss) } } + + @Test + fun `account already added dialog should be shown or hidden according to the state`() { + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + dialog = LandingState.DialogState.AccountAlreadyAdded( + accountSummary = mockk(), + ), + ) + } + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + + composeTestRule + .onNodeWithText("Account already added") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Would you like to switch to it now?") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Yes") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Cancel") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `account already added dialog Cancel click should send DialogDismiss action`() { + mutableStateFlow.update { + it.copy( + dialog = LandingState.DialogState.AccountAlreadyAdded( + accountSummary = mockk(), + ), + ) + } + + composeTestRule + .onNodeWithText("Cancel") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify { viewModel.trySendAction(LandingAction.DialogDismiss) } + } + + @Suppress("MaxLineLength") + @Test + fun `account already added dialog Yes click should send ConfirmSwitchToMatchingAccountClick action`() { + val accountSummary = mockk() + mutableStateFlow.update { + it.copy( + dialog = LandingState.DialogState.AccountAlreadyAdded( + accountSummary = accountSummary, + ), + ) + } + + composeTestRule + .onNodeWithText("Yes") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + LandingAction.ConfirmSwitchToMatchingAccountClick(account = accountSummary), + ) + } + } } private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary( 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 70d979a5d..f52faa762 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 @@ -3,14 +3,18 @@ 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.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState 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.model.AccountSummary import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummary import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -18,6 +22,7 @@ import org.junit.jupiter.api.Test class LandingViewModelTest : BaseViewModelTest() { + private val authRepository: AuthRepository = mockk(relaxed = true) private val fakeEnvironmentRepository = FakeEnvironmentRepository() @Test @@ -82,6 +87,32 @@ class LandingViewModelTest : BaseViewModelTest() { } } + @Test + fun `SwitchAccountClick should call switchAccount for the given account`() { + val matchingAccountUserId = "matchingAccountUserId" + val accountSummary = mockk { + every { userId } returns matchingAccountUserId + } + val viewModel = createViewModel() + + viewModel.trySendAction(LandingAction.SwitchAccountClick(accountSummary)) + + verify { authRepository.switchAccount(userId = matchingAccountUserId) } + } + + @Test + fun `ConfirmSwitchToMatchingAccountClick should call switchAccount for the given account`() { + val matchingAccountUserId = "matchingAccountUserId" + val accountSummary = mockk { + every { userId } returns matchingAccountUserId + } + val viewModel = createViewModel() + + viewModel.trySendAction(LandingAction.ConfirmSwitchToMatchingAccountClick(accountSummary)) + + verify { authRepository.switchAccount(userId = matchingAccountUserId) } + } + @Test fun `ContinueButtonClick with valid email should emit NavigateToLogin`() = runTest { val validEmail = "email@bitwarden.com" @@ -120,6 +151,51 @@ class LandingViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") + @Test + fun `ContinueButtonClick with an email input matching an existing account should show the account already added dialog`() { + val rememberedEmail = "active@bitwarden.com" + val activeAccount = UserState.Account( + userId = "activeUserId", + name = "name", + email = rememberedEmail, + avatarColorHex = "avatarColorHex", + isPremium = true, + isVaultUnlocked = true, + ) + val userState = UserState( + activeUserId = "activeUserId", + accounts = listOf(activeAccount), + ) + val viewModel = createViewModel( + rememberedEmail = rememberedEmail, + userState = userState, + ) + val activeAccountSummary = activeAccount.toAccountSummary(isActive = true) + val accountSummaries = userState.toAccountSummaries() + val initialState = DEFAULT_STATE.copy( + emailInput = rememberedEmail, + isContinueButtonEnabled = true, + isRememberMeEnabled = true, + accountSummaries = accountSummaries, + ) + assertEquals( + initialState, + viewModel.stateFlow.value, + ) + + viewModel.trySendAction(LandingAction.ContinueButtonClick) + + assertEquals( + initialState.copy( + dialog = LandingState.DialogState.AccountAlreadyAdded( + accountSummary = activeAccountSummary, + ), + ), + viewModel.stateFlow.value, + ) + } + @Test fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest { val viewModel = createViewModel() @@ -232,7 +308,7 @@ class LandingViewModelTest : BaseViewModelTest() { initialState = mapOf("state" to initialState), ), ): LandingViewModel = LandingViewModel( - authRepository = mockk(relaxed = true) { + authRepository = authRepository.apply { every { rememberedEmailAddress } returns rememberedEmail every { userStateFlow } returns MutableStateFlow(userState) },