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 13a5fd6eee..0038a0638f 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 @@ -93,6 +93,12 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { ) loginWithDeviceDestination( onNavigateBack = { navController.popBackStack() }, + onNavigateToTwoFactorLogin = { + navController.navigateToTwoFactorLogin( + emailAddress = it, + password = null, + ) + }, ) environmentDestination( onNavigateBack = { navController.popBackStack() }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt index 97f2d67226..ea6a4b09ce 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceNavigation.kt @@ -36,12 +36,14 @@ fun NavController.navigateToLoginWithDevice( */ fun NavGraphBuilder.loginWithDeviceDestination( onNavigateBack: () -> Unit, + onNavigateToTwoFactorLogin: (emailAddress: String) -> Unit, ) { composableWithSlideTransitions( route = LOGIN_WITH_DEVICE_ROUTE, ) { LoginWithDeviceScreen( onNavigateBack = onNavigateBack, + onNavigateToTwoFactorLogin = onNavigateToTwoFactorLogin, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt index 2ac0f65ca6..4d3c955bfb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreen.kt @@ -42,8 +42,12 @@ import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenClickableText import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent +import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager +import com.x8bit.bitwarden.ui.platform.theme.LocalIntentManager import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography @@ -55,13 +59,23 @@ import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography @Composable fun LoginWithDeviceScreen( onNavigateBack: () -> Unit, + onNavigateToTwoFactorLogin: (emailAddress: String) -> Unit, viewModel: LoginWithDeviceViewModel = hiltViewModel(), + intentManager: IntentManager = LocalIntentManager.current, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val context = LocalContext.current EventsEffect(viewModel = viewModel) { event -> when (event) { LoginWithDeviceEvent.NavigateBack -> onNavigateBack() + is LoginWithDeviceEvent.NavigateToCaptcha -> { + intentManager.startCustomTabsActivity(uri = event.uri) + } + + is LoginWithDeviceEvent.NavigateToTwoFactorLogin -> { + onNavigateToTwoFactorLogin(event.emailAddress) + } + is LoginWithDeviceEvent.ShowToast -> { Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } @@ -250,6 +264,10 @@ private fun LoginWithDeviceDialogs( onDismissDialog: () -> Unit, ) { when (state) { + is LoginWithDeviceState.DialogState.Loading -> BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(text = state.message), + ) + is LoginWithDeviceState.DialogState.Error -> BitwardenBasicDialog( visibilityState = BasicDialogState.Shown( title = state.title, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt index cb36e91629..05dd7eeec1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceViewModel.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice +import android.net.Uri import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope @@ -47,10 +48,7 @@ class LoginWithDeviceViewModel @Inject constructor( LoginWithDeviceAction.DismissDialog -> handleErrorDialogDismissed() LoginWithDeviceAction.ResendNotificationClick -> handleResendNotificationClicked() LoginWithDeviceAction.ViewAllLogInOptionsClick -> handleViewAllLogInOptionsClicked() - - is LoginWithDeviceAction.Internal.NewAuthRequestResultReceive -> { - handleNewAuthRequestResultReceived(action) - } + is LoginWithDeviceAction.Internal -> handleInternalActions(action) } } @@ -70,6 +68,14 @@ class LoginWithDeviceViewModel @Inject constructor( sendEvent(LoginWithDeviceEvent.NavigateBack) } + private fun handleInternalActions(action: LoginWithDeviceAction.Internal) { + when (action) { + is LoginWithDeviceAction.Internal.NewAuthRequestResultReceive -> { + handleNewAuthRequestResultReceived(action) + } + } + } + @Suppress("LongMethod") private fun handleNewAuthRequestResultReceived( action: LoginWithDeviceAction.Internal.NewAuthRequestResultReceive, @@ -211,6 +217,14 @@ data class LoginWithDeviceState( * Represents the current state of any dialogs on the screen. */ sealed class DialogState : Parcelable { + /** + * Displays an loading dialog to the user. + */ + @Parcelize + data class Loading( + val message: Text, + ) : DialogState() + /** * Displays an error dialog to the user. */ @@ -231,6 +245,18 @@ sealed class LoginWithDeviceEvent { */ data object NavigateBack : LoginWithDeviceEvent() + /** + * Navigates to the captcha verification screen. + */ + data class NavigateToCaptcha(val uri: Uri) : LoginWithDeviceEvent() + + /** + * Navigates to the two-factor login screen. + */ + data class NavigateToTwoFactorLogin( + val emailAddress: String, + ) : LoginWithDeviceEvent() + /** * Shows a toast with the given [message]. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt index b14ef3d21b..ff34003f19 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/loginwithdevice/LoginWithDeviceScreenTest.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice +import android.net.Uri import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.hasAnyAncestor @@ -12,18 +13,27 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager import com.x8bit.bitwarden.ui.util.isProgressBar import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify -import junit.framework.TestCase import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test class LoginWithDeviceScreenTest : BaseComposeTest() { private var onNavigateBackCalled = false + private var onNavigateToTwoFactorLoginEmail: String? = null + + private val intentManager: IntentManager = mockk { + every { startCustomTabsActivity(any()) } just runs + } private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) private val viewModel = mockk(relaxed = true) { @@ -36,7 +46,9 @@ class LoginWithDeviceScreenTest : BaseComposeTest() { composeTestRule.setContent { LoginWithDeviceScreen( onNavigateBack = { onNavigateBackCalled = true }, + onNavigateToTwoFactorLogin = { onNavigateToTwoFactorLoginEmail = it }, viewModel = viewModel, + intentManager = intentManager, ) } } @@ -87,7 +99,23 @@ class LoginWithDeviceScreenTest : BaseComposeTest() { @Test fun `NavigateBack should call onNavigateBack`() { mutableEventFlow.tryEmit(LoginWithDeviceEvent.NavigateBack) - TestCase.assertTrue(onNavigateBackCalled) + assertTrue(onNavigateBackCalled) + } + + @Test + fun `NavigateBack should call onNavigateToTwoFactorLoginEmail`() { + val email = "test@email.com" + mutableEventFlow.tryEmit(LoginWithDeviceEvent.NavigateToTwoFactorLogin(email)) + assertEquals(email, onNavigateToTwoFactorLoginEmail) + } + + @Test + fun `NavigateToCaptcha should call launchUri on intentManager`() { + val uri = mockk() + mutableEventFlow.tryEmit(LoginWithDeviceEvent.NavigateToCaptcha(uri)) + verify(exactly = 1) { + intentManager.startCustomTabsActivity(uri) + } } @Test @@ -102,6 +130,43 @@ class LoginWithDeviceScreenTest : BaseComposeTest() { } composeTestRule.onNode(isProgressBar).assertDoesNotExist() } + + @Test + fun `progress dialog should be displayed according to state`() { + val loadingMessage = "loading..." + mutableStateFlow.update { + it.copy( + dialogState = LoginWithDeviceState.DialogState.Loading(loadingMessage.asText()), + ) + } + composeTestRule + .onNodeWithText(loadingMessage) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + mutableStateFlow.update { it.copy(dialogState = null) } + composeTestRule.onNode(isDialog()).assertDoesNotExist() + } + + @Test + fun `error dialog should be displayed according to state`() { + val errorMessage = "Error" + mutableStateFlow.update { + it.copy( + dialogState = LoginWithDeviceState.DialogState.Error( + title = null, + message = errorMessage.asText(), + ), + ) + } + composeTestRule + .onNodeWithText(errorMessage) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + mutableStateFlow.update { it.copy(dialogState = null) } + composeTestRule.onNode(isDialog()).assertDoesNotExist() + } } private const val EMAIL = "test@gmail.com"