BIT-74: Add Login with Device screen (#438)

This commit is contained in:
Caleb Derosier 2023-12-28 15:55:12 -06:00 committed by Álison Fernandes
parent 0dd162598f
commit 5ac493fa89
13 changed files with 724 additions and 8 deletions

View file

@ -16,6 +16,8 @@ import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestination
import com.x8bit.bitwarden.ui.auth.feature.login.loginDestination
import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.loginWithDeviceDestination
import com.x8bit.bitwarden.ui.auth.feature.loginwithdevice.navigateToLoginWithDevice
const val AUTH_GRAPH_ROUTE: String = "auth_graph"
@ -57,6 +59,10 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) {
loginDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToEnterpriseSignOn = { navController.navigateToEnterpriseSignOn() },
onNavigateToLoginWithDevice = { navController.navigateToLoginWithDevice() },
)
loginWithDeviceDestination(
onNavigateBack = { navController.popBackStack() },
)
environmentDestination(
onNavigateBack = { navController.popBackStack() },

View file

@ -45,6 +45,7 @@ fun NavController.navigateToLogin(
fun NavGraphBuilder.loginDestination(
onNavigateBack: () -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToLoginWithDevice: () -> Unit,
) {
composable(
route = LOGIN_ROUTE,
@ -63,6 +64,7 @@ fun NavGraphBuilder.loginDestination(
LoginScreen(
onNavigateBack = onNavigateBack,
onNavigateToEnterpriseSignOn = onNavigateToEnterpriseSignOn,
onNavigateToLoginWithDevice = onNavigateToLoginWithDevice,
)
}
}

View file

@ -42,6 +42,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenOutlinedButtonWithIcon
@ -63,6 +64,7 @@ import kotlinx.collections.immutable.toImmutableList
fun LoginScreen(
onNavigateBack: () -> Unit,
onNavigateToEnterpriseSignOn: () -> Unit,
onNavigateToLoginWithDevice: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
intentHandler: IntentHandler = IntentHandler(context = LocalContext.current),
) {
@ -76,6 +78,7 @@ fun LoginScreen(
}
LoginEvent.NavigateToEnterpriseSignOn -> onNavigateToEnterpriseSignOn()
LoginEvent.NavigateToLoginWithDevice -> onNavigateToLoginWithDevice()
is LoginEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
@ -132,6 +135,9 @@ fun LoginScreen(
onLoginButtonClick = remember(viewModel) {
{ viewModel.trySendAction(LoginAction.LoginButtonClick) }
},
onLoginWithDeviceClick = remember(viewModel) {
{ viewModel.trySendAction(LoginAction.LoginWithDeviceButtonClick) }
},
onSingleSignOnClick = remember(viewModel) {
{ viewModel.trySendAction(LoginAction.SingleSignOnClick) }
},
@ -176,6 +182,7 @@ private fun LoginScreenContent(
onPasswordInputChanged: (String) -> Unit,
onMasterPasswordClick: () -> Unit,
onLoginButtonClick: () -> Unit,
onLoginWithDeviceClick: () -> Unit,
onSingleSignOnClick: () -> Unit,
onNotYouButtonClick: () -> Unit,
modifier: Modifier = Modifier,
@ -236,6 +243,18 @@ private fun LoginScreenContent(
Spacer(modifier = Modifier.height(12.dp))
// TODO BIT-808: Hide button for first-time users
BitwardenOutlinedButtonWithIcon(
label = stringResource(id = R.string.log_in_with_device),
icon = painterResource(id = R.drawable.ic_device),
onClick = onLoginWithDeviceClick,
modifier = Modifier
.semantics { testTag = "LogInWithAnotherDeviceButton" }
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
BitwardenOutlinedButtonWithIcon(
label = stringResource(id = R.string.log_in_sso),
icon = painterResource(id = R.drawable.ic_briefcase),
@ -263,15 +282,11 @@ private fun LoginScreenContent(
Spacer(modifier = Modifier.height(8.dp))
// TODO: Need to figure out better handling for very small clickable text (BIT-724)
Text(
BitwardenClickableText(
modifier = Modifier
.semantics { testTag = "NotYouLabel" }
.clickable { onNotYouButtonClick() },
text = stringResource(id = R.string.not_you),
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
.semantics { testTag = "NotYouLabel" },
onClick = onNotYouButtonClick,
label = stringResource(id = R.string.not_you),
)
Spacer(modifier = Modifier.navigationBarsPadding())
}

View file

@ -76,6 +76,7 @@ class LoginViewModel @Inject constructor(
is LoginAction.SwitchAccountClick -> handleSwitchAccountClicked(action)
is LoginAction.CloseButtonClick -> handleCloseButtonClicked()
LoginAction.LoginButtonClick -> handleLoginButtonClicked()
LoginAction.LoginWithDeviceButtonClick -> handleLoginWithDeviceButtonClicked()
LoginAction.MasterPasswordHintClick -> handleMasterPasswordHintClicked()
LoginAction.NotYouButtonClick -> handleNotYouButtonClicked()
LoginAction.SingleSignOnClick -> handleSingleSignOnClicked()
@ -172,6 +173,10 @@ class LoginViewModel @Inject constructor(
attemptLogin()
}
private fun handleLoginWithDeviceButtonClicked() {
sendEvent(LoginEvent.NavigateToLoginWithDevice)
}
private fun attemptLogin() {
mutableStateFlow.update {
it.copy(
@ -251,6 +256,11 @@ sealed class LoginEvent {
*/
data object NavigateToEnterpriseSignOn : LoginEvent()
/**
* Navigates to the login with device screen.
*/
data object NavigateToLoginWithDevice : LoginEvent()
/**
* Shows a toast with the given [message].
*/
@ -301,6 +311,11 @@ sealed class LoginAction {
*/
data object LoginButtonClick : LoginAction()
/**
* Indicates that the Login With Device button has been clicked.
*/
data object LoginWithDeviceButtonClick : LoginAction()
/**
* Indicates that the "Not you?" text was clicked.
*/

View file

@ -0,0 +1,35 @@
package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
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 LOGIN_WITH_DEVICE_ROUTE = "login_with_device"
/**
* Navigate to the Login with Device screen.
*/
fun NavController.navigateToLoginWithDevice(navOptions: NavOptions? = null) {
this.navigate(LOGIN_WITH_DEVICE_ROUTE, navOptions)
}
/**
* Add the Login with Device screen to the nav graph.
*/
fun NavGraphBuilder.loginWithDeviceDestination(
onNavigateBack: () -> Unit,
) {
composable(
route = LOGIN_WITH_DEVICE_ROUTE,
enterTransition = TransitionProviders.Enter.slideUp,
exitTransition = TransitionProviders.Exit.stay,
popEnterTransition = TransitionProviders.Enter.stay,
popExitTransition = TransitionProviders.Exit.slideDown,
) {
LoginWithDeviceScreen(
onNavigateBack = onNavigateBack,
)
}
}

View file

@ -0,0 +1,235 @@
package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
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.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
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.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.style.TextAlign
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.BitwardenClickableText
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
/**
* The top level composable for the Login with Device screen.
*/
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginWithDeviceScreen(
onNavigateBack: () -> Unit,
viewModel: LoginWithDeviceViewModel = hiltViewModel(),
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val context = LocalContext.current
EventsEffect(viewModel = viewModel) { event ->
when (event) {
LoginWithDeviceEvent.NavigateBack -> onNavigateBack()
is LoginWithDeviceEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.log_in_with_device),
scrollBehavior = scrollBehavior,
navigationIcon = painterResource(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{ viewModel.trySendAction(LoginWithDeviceAction.CloseButtonClick) }
},
)
},
) { paddingValues ->
val modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
when (val viewState = state.viewState) {
is LoginWithDeviceState.ViewState.Content -> {
LoginWithDeviceScreenContent(
state = viewState,
onResendNotificationClick = remember(viewModel) {
{ viewModel.trySendAction(LoginWithDeviceAction.ResendNotificationClick) }
},
onViewAllLogInOptionsClick = remember(viewModel) {
{ viewModel.trySendAction(LoginWithDeviceAction.ViewAllLogInOptionsClick) }
},
modifier = modifier,
)
}
is LoginWithDeviceState.ViewState.Error -> {
BitwardenErrorContent(
message = viewState.message(),
modifier = modifier,
)
}
LoginWithDeviceState.ViewState.Loading -> {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}
}
@Suppress("LongMethod")
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun LoginWithDeviceScreenContent(
state: LoginWithDeviceState.ViewState.Content,
onResendNotificationClick: () -> Unit,
onViewAllLogInOptionsClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.semantics { testTagsAsResourceId = true }
.imePadding()
.verticalScroll(rememberScrollState()),
) {
Text(
text = stringResource(id = R.string.log_in_initiated),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.a_notification_has_been_sent_to_your_device),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
@Suppress("MaxLineLength")
Text(
text = stringResource(id = R.string.please_make_sure_your_vault_is_unlocked_and_the_fingerprint_phrase_matches_on_the_other_device),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.fingerprint_phrase),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = state.fingerprintPhrase,
textAlign = TextAlign.Start,
color = LocalNonMaterialColors.current.fingerprint,
style = LocalNonMaterialTypography.current.sensitiveInfoSmall,
modifier = Modifier
.semantics { testTag = "FingerprintValueLabel" }
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
BitwardenClickableText(
modifier = Modifier
.padding(horizontal = 16.dp)
.semantics { testTag = "ResendNotificationButton" }
.fillMaxWidth(),
label = stringResource(id = R.string.resend_notification),
onClick = onResendNotificationClick,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(id = R.string.need_another_option),
textAlign = TextAlign.Start,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
BitwardenClickableText(
modifier = Modifier
.padding(horizontal = 16.dp)
.semantics { testTag = "ViewAllLoginOptionsButton" }
.fillMaxWidth(),
label = stringResource(id = R.string.view_all_login_options),
onClick = onViewAllLogInOptionsClick,
)
Spacer(modifier = Modifier.navigationBarsPadding())
}
}

View file

@ -0,0 +1,136 @@
package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.update
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Manages application state for the Login with Device screen.
*/
@HiltViewModel
class LoginWithDeviceViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
) : BaseViewModel<LoginWithDeviceState, LoginWithDeviceEvent, LoginWithDeviceAction>(
initialState = savedStateHandle[KEY_STATE]
?: LoginWithDeviceState(
viewState = LoginWithDeviceState.ViewState.Loading,
),
) {
init {
mutableStateFlow.update {
// TODO BIT-809: Pull phrase from SDK
it.copy(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate",
),
)
}
}
override fun handleAction(action: LoginWithDeviceAction) {
when (action) {
LoginWithDeviceAction.CloseButtonClick -> handleCloseButtonClicked()
LoginWithDeviceAction.ResendNotificationClick -> handleResendNotificationClicked()
LoginWithDeviceAction.ViewAllLogInOptionsClick -> handleViewAllLogInOptionsClicked()
}
}
private fun handleCloseButtonClicked() {
sendEvent(LoginWithDeviceEvent.NavigateBack)
}
private fun handleResendNotificationClicked() {
// TODO BIT-810: implement Resend Notification button
sendEvent(LoginWithDeviceEvent.ShowToast("Not yet implemented."))
}
private fun handleViewAllLogInOptionsClicked() {
sendEvent(LoginWithDeviceEvent.NavigateBack)
}
}
/**
* Models state of the Login with Device screen.
*/
@Parcelize
data class LoginWithDeviceState(
val viewState: ViewState,
) : Parcelable {
/**
* Represents the specific view states for the [LoginWithDeviceScreen].
*/
@Parcelize
sealed class ViewState : Parcelable {
/**
* Loading state for the [LoginWithDeviceScreen], signifying that the content is being
* processed.
*/
@Parcelize
data object Loading : ViewState()
/**
* Represents a state where the [LoginWithDeviceScreen] is unable to display data due to an
* error retrieving it.
*
* @property message The message to display on the error screen.
*/
@Parcelize
data class Error(
val message: Text,
) : ViewState()
/**
* Content state for the [LoginWithDeviceScreen] showing the actual content or items.
*
* @property fingerprintPhrase The fingerprint phrase to present to the user.
*/
@Parcelize
data class Content(
val fingerprintPhrase: String,
) : ViewState()
}
}
/**
* Models events for the Login with Device screen.
*/
sealed class LoginWithDeviceEvent {
/**
* Navigates back to the previous screen.
*/
data object NavigateBack : LoginWithDeviceEvent()
/**
* Shows a toast with the given [message].
*/
data class ShowToast(
val message: String,
) : LoginWithDeviceEvent()
}
/**
* Models actions for the Login with Device screen.
*/
sealed class LoginWithDeviceAction {
/**
* Indicates that the top-bar close button was clicked.
*/
data object CloseButtonClick : LoginWithDeviceAction()
/**
* Indicates that the "Resend notification" text has been clicked.
*/
data object ResendNotificationClick : LoginWithDeviceAction()
/**
* Indicates that the "View all log in options" text has been clicked.
*/
data object ViewAllLogInOptionsClick : LoginWithDeviceAction()
}

View file

@ -0,0 +1,42 @@
package com.x8bit.bitwarden.ui.platform.components
import androidx.compose.foundation.clickable
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
/**
* Represents a Bitwarden-styled clickable text.
*
* @param label The label for the button.
* @param onClick The callback when the button is clicked.
* @param modifier The [Modifier] to be applied to the button.
*/
@Composable
fun BitwardenClickableText(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier
// TODO: Need to figure out better handling for very small clickable text (BIT-724)
.clickable { onClick() },
text = label,
textAlign = TextAlign.Start,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.labelLarge,
)
}
@Preview
@Composable
private fun BitwardenTextButton_preview() {
BitwardenTextButton(
label = "Label",
onClick = {},
)
}

View file

@ -0,0 +1,21 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="19dp"
android:height="18dp"
android:viewportHeight="18"
android:viewportWidth="19">
<group>
<clip-path android:pathData="M2,1.5h15v15h-15z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M5,4.406C5,4.147 5.219,3.938 5.488,3.938L13.137,3.938C13.406,3.938 13.625,4.147 13.625,4.406C13.625,4.665 13.406,4.875 13.137,4.875L5.488,4.875C5.219,4.875 5,4.665 5,4.406Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M5,13.031C5,12.772 5.219,12.563 5.488,12.563H13.137C13.406,12.563 13.625,12.772 13.625,13.031C13.625,13.29 13.406,13.5 13.137,13.5H5.488C5.219,13.5 5,13.29 5,13.031Z" />
<path
android:fillColor="#175DDC"
android:fillType="evenOdd"
android:pathData="M6.406,1.5H12.594C13.37,1.5 14,2.13 14,2.906V15.094C14,15.87 13.37,16.5 12.594,16.5H6.406C5.63,16.5 5,15.87 5,15.094L5,2.906C5,2.13 5.63,1.5 6.406,1.5ZM6.406,2.438C6.147,2.438 5.938,2.647 5.938,2.906V15.094C5.938,15.353 6.147,15.563 6.406,15.563H12.594C12.853,15.563 13.063,15.353 13.063,15.094V2.906C13.063,2.647 12.853,2.438 12.594,2.438H6.406Z" />
</group>
</vector>

View file

@ -46,6 +46,7 @@ class LoginScreenTest : BaseComposeTest() {
}
private var onNavigateBackCalled = false
private var onNavigateToEnterpriseSignOnCalled = false
private var onNavigateToLoginWithDeviceCalled = false
private val mutableEventFlow = MutableSharedFlow<LoginEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
@ -61,6 +62,7 @@ class LoginScreenTest : BaseComposeTest() {
LoginScreen(
onNavigateBack = { onNavigateBackCalled = true },
onNavigateToEnterpriseSignOn = { onNavigateToEnterpriseSignOnCalled = true },
onNavigateToLoginWithDevice = { onNavigateToLoginWithDeviceCalled = true },
viewModel = viewModel,
intentHandler = intentHandler,
)
@ -273,6 +275,12 @@ class LoginScreenTest : BaseComposeTest() {
mutableEventFlow.tryEmit(LoginEvent.NavigateToEnterpriseSignOn)
assertTrue(onNavigateToEnterpriseSignOnCalled)
}
@Test
fun `NavigateToLoginWithDevice should call onNavigateToLoginWithDevice`() {
mutableEventFlow.tryEmit(LoginEvent.NavigateToLoginWithDevice)
assertTrue(onNavigateToLoginWithDeviceCalled)
}
}
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(

View file

@ -317,6 +317,19 @@ class LoginViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `LoginWithDeviceButtonClick should emit NavigateToLoginWithDevice`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginAction.LoginWithDeviceButtonClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
LoginEvent.NavigateToLoginWithDevice,
awaitItem(),
)
}
}
@Test
fun `SingleSignOnClick should emit NavigateToEnterpriseSignOn`() = runTest {
val viewModel = createViewModel()

View file

@ -0,0 +1,116 @@
package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.util.isProgressBar
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import junit.framework.TestCase
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Before
import org.junit.Test
class LoginWithDeviceScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val mutableEventFlow = MutableSharedFlow<LoginWithDeviceEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<LoginWithDeviceViewModel>(relaxed = true) {
every { eventFlow } returns mutableEventFlow
every { stateFlow } returns mutableStateFlow
}
@Before
fun setup() {
composeTestRule.setContent {
LoginWithDeviceScreen(
onNavigateBack = { onNavigateBackCalled = true },
viewModel = viewModel,
)
}
}
@Test
fun `close button click should send CloseButtonClick action`() {
composeTestRule.onNodeWithContentDescription("Close").performClick()
verify {
viewModel.trySendAction(LoginWithDeviceAction.CloseButtonClick)
}
}
@Test
fun `resend notification click should send ResendNotificationClick action`() {
composeTestRule.onNodeWithText("Resend notification").performClick()
verify {
viewModel.trySendAction(LoginWithDeviceAction.ResendNotificationClick)
}
}
@Test
fun `view all log in options click should send ViewAllLogInOptionsClick action`() {
composeTestRule.onNodeWithText("View all log in options").performScrollTo().performClick()
verify {
viewModel.trySendAction(LoginWithDeviceAction.ViewAllLogInOptionsClick)
}
}
@Test
fun `NavigateBack should call onNavigateBack`() {
mutableEventFlow.tryEmit(LoginWithDeviceEvent.NavigateBack)
TestCase.assertTrue(onNavigateBackCalled)
}
@Test
fun `progress bar should be displayed according to state`() {
mutableStateFlow.update {
it.copy(viewState = LoginWithDeviceState.ViewState.Loading)
}
composeTestRule.onNode(isProgressBar).assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = LoginWithDeviceState.ViewState.Error("Failure".asText()))
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_STATE.viewState)
}
composeTestRule.onNode(isProgressBar).assertDoesNotExist()
}
@Test
fun `error should be displayed according to state`() {
val errorMessage = "error"
mutableStateFlow.update {
it.copy(viewState = LoginWithDeviceState.ViewState.Loading)
}
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(viewState = LoginWithDeviceState.ViewState.Error(errorMessage.asText()))
}
composeTestRule.onNodeWithText(errorMessage).assertIsDisplayed()
mutableStateFlow.update {
it.copy(viewState = DEFAULT_STATE.viewState)
}
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
}
companion object {
private val DEFAULT_STATE = LoginWithDeviceState(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate",
),
)
}
}

View file

@ -0,0 +1,72 @@
package com.x8bit.bitwarden.ui.auth.feature.loginwithdevice
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class LoginWithDeviceViewModelTest : BaseViewModelTest() {
private val savedStateHandle = SavedStateHandle()
@Test
fun `initial state should be correct`() = runTest {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@Test
fun `CloseButtonClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginWithDeviceAction.CloseButtonClick)
assertEquals(
LoginWithDeviceEvent.NavigateBack,
awaitItem(),
)
}
}
@Test
fun `ResendNotificationClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginWithDeviceAction.ResendNotificationClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
LoginWithDeviceEvent.ShowToast("Not yet implemented."),
awaitItem(),
)
}
}
@Test
fun `ViewAllLogInOptionsClick should emit NavigateBack`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.actionChannel.trySend(LoginWithDeviceAction.ViewAllLogInOptionsClick)
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
assertEquals(
LoginWithDeviceEvent.NavigateBack,
awaitItem(),
)
}
}
private fun createViewModel(): LoginWithDeviceViewModel =
LoginWithDeviceViewModel(
savedStateHandle = savedStateHandle,
)
companion object {
private val DEFAULT_STATE = LoginWithDeviceState(
viewState = LoginWithDeviceState.ViewState.Content(
fingerprintPhrase = "alabster-drinkable-mystified-rapping-irrigate",
),
)
}
}