PM-13067 Navigate to setup unlock screen from action card in security settings (#4023)

This commit is contained in:
Dave Severns 2024-10-04 14:29:47 -04:00 committed by GitHub
parent 83652c9699
commit 8ae6433906
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 299 additions and 76 deletions

View file

@ -1,29 +1,88 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.NavType
import androidx.navigation.navArgument
import com.x8bit.bitwarden.data.platform.annotation.OmitFromCoverage
import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithSlideTransitions
/** /**
* Route for [SetupUnlockScreen] * Route constants for [SetupUnlockScreen]
*/ */
const val SETUP_UNLOCK_ROUTE = "setup_unlock" private const val SETUP_UNLOCK_PREFIX = "setup_unlock"
private const val SETUP_UNLOCK_AS_ROOT_PREFIX = "${SETUP_UNLOCK_PREFIX}_as_root"
private const val SETUP_UNLOCK_INITIAL_SETUP_ARG = "isInitialSetup"
const val SETUP_UNLOCK_AS_ROOT_ROUTE = "$SETUP_UNLOCK_AS_ROOT_PREFIX/" +
"{$SETUP_UNLOCK_INITIAL_SETUP_ARG}"
private const val SETUP_UNLOCK_ROUTE = "$SETUP_UNLOCK_PREFIX/{$SETUP_UNLOCK_INITIAL_SETUP_ARG}"
/**
* Class to retrieve setup unlock arguments from the [SavedStateHandle].
*/
@OmitFromCoverage
data class SetupUnlockArgs(
val isInitialSetup: Boolean,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
isInitialSetup = requireNotNull(savedStateHandle[SETUP_UNLOCK_INITIAL_SETUP_ARG]),
)
}
/** /**
* Navigate to the setup unlock screen. * Navigate to the setup unlock screen.
*/ */
fun NavController.navigateToSetupUnlockScreen(navOptions: NavOptions? = null) { fun NavController.navigateToSetupUnlockScreen(navOptions: NavOptions? = null) {
this.navigate(SETUP_UNLOCK_ROUTE, navOptions) this.navigate("$SETUP_UNLOCK_PREFIX/false", navOptions)
} }
/** /**
* Add the setup unlock screen to the nav graph. * Navigate to the setup unlock screen as root.
*/ */
fun NavGraphBuilder.setupUnlockDestination() { fun NavController.navigateToSetupUnlockScreenAsRoot(navOptions: NavOptions? = null) {
composableWithPushTransitions( this.navigate("$SETUP_UNLOCK_AS_ROOT_PREFIX/true", navOptions)
}
/**
* Add the setup unlock screen to a nav graph.
*/
fun NavGraphBuilder.setupUnlockDestination(
onNavigateBack: () -> Unit,
) {
composableWithSlideTransitions(
route = SETUP_UNLOCK_ROUTE, route = SETUP_UNLOCK_ROUTE,
arguments = setupUnlockArguments,
) { ) {
SetupUnlockScreen() SetupUnlockScreen(
onNavigateBack = onNavigateBack,
)
} }
} }
/**
* Add the setup unlock screen to the root nav graph.
*/
fun NavGraphBuilder.setupUnlockDestinationAsRoot() {
composableWithPushTransitions(
route = SETUP_UNLOCK_AS_ROOT_ROUTE,
arguments = setupUnlockArguments,
) {
SetupUnlockScreen(
onNavigateBack = {
// No-Op
},
)
}
}
private val setupUnlockArguments = listOf(
navArgument(
name = SETUP_UNLOCK_INITIAL_SETUP_ARG,
builder = {
type = NavType.BoolType
},
),
)

View file

@ -40,6 +40,7 @@ import com.x8bit.bitwarden.ui.auth.feature.accountsetup.handlers.SetupUnlockHand
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.x8bit.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.appbar.NavigationIcon
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState import com.x8bit.bitwarden.ui.platform.components.dialog.BasicDialogState
@ -60,10 +61,12 @@ import com.x8bit.bitwarden.ui.platform.util.isPortrait
* Top level composable for the setup unlock screen. * Top level composable for the setup unlock screen.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable @Composable
fun SetupUnlockScreen( fun SetupUnlockScreen(
viewModel: SetupUnlockViewModel = hiltViewModel(), viewModel: SetupUnlockViewModel = hiltViewModel(),
biometricsManager: BiometricsManager = LocalBiometricsManager.current, biometricsManager: BiometricsManager = LocalBiometricsManager.current,
onNavigateBack: () -> Unit,
) { ) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle() val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handler = remember(viewModel) { SetupUnlockHandler.create(viewModel = viewModel) } val handler = remember(viewModel) { SetupUnlockHandler.create(viewModel = viewModel) }
@ -83,6 +86,8 @@ fun SetupUnlockScreen(
cipher = event.cipher, cipher = event.cipher,
) )
} }
SetupUnlockEvent.NavigateBack -> onNavigateBack()
} }
} }
@ -100,9 +105,27 @@ fun SetupUnlockScreen(
.nestedScroll(scrollBehavior.nestedScrollConnection), .nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
BitwardenTopAppBar( BitwardenTopAppBar(
title = stringResource(id = R.string.account_setup), title = stringResource(
id = if (state.isInitialSetup) {
R.string.account_setup
} else {
R.string.set_up_unlock
},
),
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
navigationIcon = null, navigationIcon = if (state.isInitialSetup) {
null
} else {
NavigationIcon(
navigationIcon = rememberVectorPainter(id = R.drawable.ic_close),
navigationIconContentDescription = stringResource(id = R.string.close),
onNavigationIconClick = remember(viewModel) {
{
viewModel.trySendAction(SetupUnlockAction.CloseClick)
}
},
)
},
) )
}, },
) { innerPadding -> ) { innerPadding ->
@ -169,14 +192,16 @@ private fun SetupUnlockScreenContent(
) )
Spacer(modifier = Modifier.height(height = 12.dp)) Spacer(modifier = Modifier.height(height = 12.dp))
SetUpLaterButton( if (state.isInitialSetup) {
onConfirmClick = handler.onSetUpLaterClick, SetUpLaterButton(
modifier = Modifier onConfirmClick = handler.onSetUpLaterClick,
.fillMaxWidth() modifier = Modifier
.standardHorizontalMargin(), .fillMaxWidth()
) .standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp)) Spacer(modifier = Modifier.height(height = 12.dp))
}
Spacer(modifier = Modifier.navigationBarsPadding()) Spacer(modifier = Modifier.navigationBarsPadding())
} }
} }

View file

@ -25,6 +25,7 @@ private const val KEY_STATE = "state"
/** /**
* Models logic for the setup unlock screen. * Models logic for the setup unlock screen.
*/ */
@Suppress("TooManyFunctions")
@HiltViewModel @HiltViewModel
class SetupUnlockViewModel @Inject constructor( class SetupUnlockViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
@ -38,6 +39,8 @@ class SetupUnlockViewModel @Inject constructor(
userId = userId, userId = userId,
cipher = biometricsEncryptionManager.getOrCreateCipher(userId = userId), cipher = biometricsEncryptionManager.getOrCreateCipher(userId = userId),
) )
// whether or not the user has completed the initial setup prior to this.
val isInitialSetup = SetupUnlockArgs(savedStateHandle).isInitialSetup
SetupUnlockState( SetupUnlockState(
userId = userId, userId = userId,
isUnlockWithPasswordEnabled = authRepository isUnlockWithPasswordEnabled = authRepository
@ -49,6 +52,7 @@ class SetupUnlockViewModel @Inject constructor(
isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled && isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled &&
isBiometricsValid, isBiometricsValid,
dialogState = null, dialogState = null,
isInitialSetup = isInitialSetup,
) )
}, },
) { ) {
@ -64,11 +68,20 @@ class SetupUnlockViewModel @Inject constructor(
is SetupUnlockAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action) is SetupUnlockAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action)
is SetupUnlockAction.Internal -> handleInternalActions(action) is SetupUnlockAction.Internal -> handleInternalActions(action)
SetupUnlockAction.CloseClick -> handleCloseClick()
} }
} }
private fun handleCloseClick() {
sendEvent(SetupUnlockEvent.NavigateBack)
}
private fun handleContinueClick() { private fun handleContinueClick() {
updateOnboardingStatusToNextStep() if (state.isInitialSetup) {
updateOnboardingStatusToNextStep()
} else {
sendEvent(SetupUnlockEvent.NavigateBack)
}
} }
private fun handleEnableBiometricsClick() { private fun handleEnableBiometricsClick() {
@ -196,6 +209,7 @@ data class SetupUnlockState(
val isUnlockWithPinEnabled: Boolean, val isUnlockWithPinEnabled: Boolean,
val isUnlockWithBiometricsEnabled: Boolean, val isUnlockWithBiometricsEnabled: Boolean,
val dialogState: DialogState?, val dialogState: DialogState?,
val isInitialSetup: Boolean,
) : Parcelable { ) : Parcelable {
/** /**
* Indicates whether the continue button should be enabled or disabled. * Indicates whether the continue button should be enabled or disabled.
@ -237,6 +251,11 @@ sealed class SetupUnlockEvent {
data class ShowBiometricsPrompt( data class ShowBiometricsPrompt(
val cipher: Cipher, val cipher: Cipher,
) : SetupUnlockEvent() ) : SetupUnlockEvent()
/**
* Navigates back to the previous screen.
*/
data object NavigateBack : SetupUnlockEvent()
} }
/** /**
@ -277,6 +296,11 @@ sealed class SetupUnlockAction {
*/ */
data object DismissDialog : SetupUnlockAction() data object DismissDialog : SetupUnlockAction()
/**
* The user has clicked the close button.
*/
data object CloseClick : SetupUnlockAction()
/** /**
* Models actions that can be sent by the view model itself. * Models actions that can be sent by the view model itself.
*/ */

View file

@ -17,13 +17,13 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_AUTO_FILL_ROUTE import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_AUTO_FILL_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_COMPLETE_ROUTE import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_COMPLETE_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_UNLOCK_ROUTE import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SETUP_UNLOCK_AS_ROOT_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupAutoFillScreen import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupAutoFillScreen
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupCompleteScreen import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupCompleteScreen
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupUnlockScreen import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupUnlockScreenAsRoot
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupAutoFillDestination import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupAutoFillDestination
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupCompleteDestination import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupCompleteDestination
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupUnlockDestination import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupUnlockDestinationAsRoot
import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE
import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph
import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph
@ -99,7 +99,7 @@ fun RootNavScreen(
vaultUnlockDestination() vaultUnlockDestination()
vaultUnlockedGraph(navController) vaultUnlockedGraph(navController)
setupDebugMenuDestination(onNavigateBack = { navController.popBackStack() }) setupDebugMenuDestination(onNavigateBack = { navController.popBackStack() })
setupUnlockDestination() setupUnlockDestinationAsRoot()
setupAutoFillDestination() setupAutoFillDestination()
setupCompleteDestination() setupCompleteDestination()
} }
@ -127,7 +127,7 @@ fun RootNavScreen(
is RootNavState.VaultUnlockedForFido2GetCredentials, is RootNavState.VaultUnlockedForFido2GetCredentials,
-> VAULT_UNLOCKED_GRAPH_ROUTE -> VAULT_UNLOCKED_GRAPH_ROUTE
RootNavState.OnboardingAccountLockSetup -> SETUP_UNLOCK_ROUTE RootNavState.OnboardingAccountLockSetup -> SETUP_UNLOCK_AS_ROOT_ROUTE
RootNavState.OnboardingAutoFillSetup -> SETUP_AUTO_FILL_ROUTE RootNavState.OnboardingAutoFillSetup -> SETUP_AUTO_FILL_ROUTE
RootNavState.OnboardingStepsComplete -> SETUP_COMPLETE_ROUTE RootNavState.OnboardingStepsComplete -> SETUP_COMPLETE_ROUTE
} }
@ -235,7 +235,7 @@ fun RootNavScreen(
} }
RootNavState.OnboardingAccountLockSetup -> { RootNavState.OnboardingAccountLockSetup -> {
navController.navigateToSetupUnlockScreen(rootNavOptions) navController.navigateToSetupUnlockScreenAsRoot(rootNavOptions)
} }
RootNavState.OnboardingAutoFillSetup -> { RootNavState.OnboardingAutoFillSetup -> {

View file

@ -4,6 +4,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions import androidx.navigation.NavOptions
import androidx.navigation.navigation import androidx.navigation.navigation
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.navigateToSetupUnlockScreen
import com.x8bit.bitwarden.ui.platform.base.util.composableWithRootPushTransitions import com.x8bit.bitwarden.ui.platform.base.util.composableWithRootPushTransitions
import com.x8bit.bitwarden.ui.platform.feature.settings.about.aboutDestination import com.x8bit.bitwarden.ui.platform.feature.settings.about.aboutDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.about.navigateToAbout import com.x8bit.bitwarden.ui.platform.feature.settings.about.navigateToAbout
@ -26,6 +27,7 @@ private const val SETTINGS_ROUTE: String = "settings"
/** /**
* Add settings destinations to the nav graph. * Add settings destinations to the nav graph.
*/ */
@Suppress("LongParameterList")
fun NavGraphBuilder.settingsGraph( fun NavGraphBuilder.settingsGraph(
navController: NavController, navController: NavController,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
@ -54,6 +56,7 @@ fun NavGraphBuilder.settingsGraph(
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToPendingRequests = onNavigateToPendingRequests, onNavigateToPendingRequests = onNavigateToPendingRequests,
onNavigateToSetupUnlockScreen = { navController.navigateToSetupUnlockScreen() },
) )
appearanceDestination(onNavigateBack = { navController.popBackStack() }) appearanceDestination(onNavigateBack = { navController.popBackStack() })
autoFillDestination( autoFillDestination(

View file

@ -14,6 +14,7 @@ fun NavGraphBuilder.accountSecurityDestination(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToPendingRequests: () -> Unit, onNavigateToPendingRequests: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit,
) { ) {
composableWithPushTransitions( composableWithPushTransitions(
route = ACCOUNT_SECURITY_ROUTE, route = ACCOUNT_SECURITY_ROUTE,
@ -22,6 +23,7 @@ fun NavGraphBuilder.accountSecurityDestination(
onNavigateBack = onNavigateBack, onNavigateBack = onNavigateBack,
onNavigateToDeleteAccount = onNavigateToDeleteAccount, onNavigateToDeleteAccount = onNavigateToDeleteAccount,
onNavigateToPendingRequests = onNavigateToPendingRequests, onNavigateToPendingRequests = onNavigateToPendingRequests,
onNavigateToSetupUnlockScreen = onNavigateToSetupUnlockScreen,
) )
} }
} }

View file

@ -82,6 +82,7 @@ fun AccountSecurityScreen(
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onNavigateToDeleteAccount: () -> Unit, onNavigateToDeleteAccount: () -> Unit,
onNavigateToPendingRequests: () -> Unit, onNavigateToPendingRequests: () -> Unit,
onNavigateToSetupUnlockScreen: () -> Unit,
viewModel: AccountSecurityViewModel = hiltViewModel(), viewModel: AccountSecurityViewModel = hiltViewModel(),
biometricsManager: BiometricsManager = LocalBiometricsManager.current, biometricsManager: BiometricsManager = LocalBiometricsManager.current,
intentManager: IntentManager = LocalIntentManager.current, intentManager: IntentManager = LocalIntentManager.current,
@ -140,6 +141,8 @@ fun AccountSecurityScreen(
is AccountSecurityEvent.ShowToast -> { is AccountSecurityEvent.ShowToast -> {
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
} }
AccountSecurityEvent.NavigateToSetupUnlockScreen -> onNavigateToSetupUnlockScreen()
} }
} }

View file

@ -165,7 +165,7 @@ class AccountSecurityViewModel @Inject constructor(
private fun handleUnlockCardCtaClick() { private fun handleUnlockCardCtaClick() {
dismissUnlockNotificationBadge() dismissUnlockNotificationBadge()
// TODO: Navigate to unlock set up screen PM-13067 sendEvent(AccountSecurityEvent.NavigateToSetupUnlockScreen)
} }
private fun handleAccountFingerprintPhraseClick() { private fun handleAccountFingerprintPhraseClick() {
@ -564,6 +564,11 @@ sealed class AccountSecurityEvent {
data class ShowToast( data class ShowToast(
val text: Text, val text: Text,
) : AccountSecurityEvent() ) : AccountSecurityEvent()
/**
* Navigate to the setup unlock screen.
*/
data object NavigateToSetupUnlockScreen : AccountSecurityEvent()
} }
/** /**

View file

@ -34,6 +34,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navOptions import androidx.navigation.navOptions
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.setupUnlockDestination
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.max import com.x8bit.bitwarden.ui.platform.base.util.max
import com.x8bit.bitwarden.ui.platform.base.util.toDp import com.x8bit.bitwarden.ui.platform.base.util.toDp
@ -235,6 +236,11 @@ private fun VaultUnlockedNavBarScaffold(
onNavigateToFolders = navigateToFolders, onNavigateToFolders = navigateToFolders,
onNavigateToPendingRequests = navigateToPendingRequests, onNavigateToPendingRequests = navigateToPendingRequests,
) )
setupUnlockDestination(
onNavigateBack = {
navController.popBackStack()
},
)
} }
} }
} }

View file

@ -8,6 +8,7 @@ import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performScrollTo
@ -24,6 +25,7 @@ import io.mockk.mockk
import io.mockk.runs import io.mockk.runs
import io.mockk.slot import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.junit.Before import org.junit.Before
@ -32,7 +34,7 @@ import org.robolectric.annotation.Config
import javax.crypto.Cipher import javax.crypto.Cipher
class SetupUnlockScreenTest : BaseComposeTest() { class SetupUnlockScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
private val captureBiometricsSuccess = slot<(cipher: Cipher?) -> Unit>() private val captureBiometricsSuccess = slot<(cipher: Cipher?) -> Unit>()
private val captureBiometricsCancel = slot<() -> Unit>() private val captureBiometricsCancel = slot<() -> Unit>()
private val captureBiometricsLockOut = slot<() -> Unit>() private val captureBiometricsLockOut = slot<() -> Unit>()
@ -64,6 +66,7 @@ class SetupUnlockScreenTest : BaseComposeTest() {
SetupUnlockScreen( SetupUnlockScreen(
viewModel = viewModel, viewModel = viewModel,
biometricsManager = biometricsManager, biometricsManager = biometricsManager,
onNavigateBack = { onNavigateBackCalled = true },
) )
} }
} }
@ -509,6 +512,15 @@ class SetupUnlockScreenTest : BaseComposeTest() {
} }
} }
@Test
fun `on Set up later component should not be displayed when not in initial setup`() {
mutableStateFlow.update { it.copy(isInitialSetup = false) }
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(text = "Set up later")
.assertDoesNotExist()
}
@Test @Test
fun `on Set up later click should display confirmation dialog`() { fun `on Set up later click should display confirmation dialog`() {
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
@ -610,6 +622,30 @@ class SetupUnlockScreenTest : BaseComposeTest() {
mutableStateFlow.update { it.copy(dialogState = null) } mutableStateFlow.update { it.copy(dialogState = null) }
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
} }
@Test
fun `on NavigateBack event should invoke onNavigateBack`() {
mutableEventFlow.tryEmit(SetupUnlockEvent.NavigateBack)
assertTrue(onNavigateBackCalled)
}
@Test
fun `close icon should not show when in initial setup`() {
composeTestRule
.onNodeWithContentDescription(label = "Close")
.assertDoesNotExist()
}
@Test
fun `close icon should show when not initial setup and send action when clicked`() {
mutableStateFlow.update { it.copy(isInitialSetup = false) }
composeTestRule
.onNodeWithContentDescription(label = "Close")
.assertIsDisplayed()
.performClick()
verify { viewModel.trySendAction(SetupUnlockAction.CloseClick) }
}
} }
private const val DEFAULT_USER_ID: String = "user_id" private const val DEFAULT_USER_ID: String = "user_id"
@ -619,6 +655,7 @@ private val DEFAULT_STATE: SetupUnlockState = SetupUnlockState(
isUnlockWithPasswordEnabled = true, isUnlockWithPasswordEnabled = true,
isUnlockWithBiometricsEnabled = false, isUnlockWithBiometricsEnabled = false,
dialogState = null, dialogState = null,
isInitialSetup = true,
) )
private val CIPHER = mockk<Cipher>() private val CIPHER = mockk<Cipher>()

View file

@ -56,10 +56,18 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
} }
@Test
fun `initial state should be correct when not initial setup`() {
val viewModel = createViewModel(DEFAULT_STATE.copy(isInitialSetup = false))
assertEquals(
DEFAULT_STATE.copy(isInitialSetup = false),
viewModel.stateFlow.value,
)
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `ContinueClick should call setOnboardingStatus and set to AUTOFILL_SETUP if AutoFill is not enabled`() = fun `ContinueClick should call setOnboardingStatus and set to AUTOFILL_SETUP if AutoFill is not enabled`() {
runTest {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(SetupUnlockAction.ContinueClick) viewModel.trySendAction(SetupUnlockAction.ContinueClick)
verify { verify {
@ -72,8 +80,25 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `SetUpLaterClick should call setOnboardingStatus and set to AUTOFILL_SETUP if AutoFill is not enabled`() = fun `ContinueClick should send NavigateBack event if this is not the initial setup`() =
runTest { runTest {
val viewModel = createViewModel(DEFAULT_STATE.copy(isInitialSetup = false))
viewModel.eventFlow.test {
viewModel.trySendAction(SetupUnlockAction.ContinueClick)
assertEquals(SetupUnlockEvent.NavigateBack, awaitItem())
}
verify(exactly = 0) {
authRepository.setOnboardingStatus(
userId = DEFAULT_USER_ID,
status = OnboardingStatus.AUTOFILL_SETUP,
)
}
}
@Suppress("MaxLineLength")
@Test
fun `SetUpLaterClick should call setOnboardingStatus and set to AUTOFILL_SETUP if AutoFill is not enabled`() {
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick) viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick)
verify { verify {
@ -87,18 +112,17 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
fun `ContinueClick should call setOnboardingStatus and set to FINAL_STEP if AutoFill is already enabled`() = fun `ContinueClick should call setOnboardingStatus and set to FINAL_STEP if AutoFill is already enabled`() {
runTest { mutableAutofillEnabledStateFlow.update { true }
mutableAutofillEnabledStateFlow.update { true } val viewModel = createViewModel()
val viewModel = createViewModel() viewModel.trySendAction(SetupUnlockAction.ContinueClick)
viewModel.trySendAction(SetupUnlockAction.ContinueClick) verify {
verify { authRepository.setOnboardingStatus(
authRepository.setOnboardingStatus( userId = DEFAULT_USER_ID,
userId = DEFAULT_USER_ID, status = OnboardingStatus.FINAL_STEP,
status = OnboardingStatus.FINAL_STEP, )
)
}
} }
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
@ -116,24 +140,23 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `on UnlockWithBiometricToggle false should call clearBiometricsKey and update the state`() = fun `on UnlockWithBiometricToggle false should call clearBiometricsKey and update the state`() {
runTest { val initialState = DEFAULT_STATE.copy(isUnlockWithBiometricsEnabled = true)
val initialState = DEFAULT_STATE.copy(isUnlockWithBiometricsEnabled = true) every { settingsRepository.isUnlockWithBiometricsEnabled } returns true
every { settingsRepository.isUnlockWithBiometricsEnabled } returns true every { settingsRepository.clearBiometricsKey() } just runs
every { settingsRepository.clearBiometricsKey() } just runs val viewModel = createViewModel(initialState)
val viewModel = createViewModel(initialState) assertEquals(initialState, viewModel.stateFlow.value)
assertEquals(initialState, viewModel.stateFlow.value)
viewModel.trySendAction(SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = false)) viewModel.trySendAction(SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = false))
assertEquals( assertEquals(
initialState.copy(isUnlockWithBiometricsEnabled = false), initialState.copy(isUnlockWithBiometricsEnabled = false),
viewModel.stateFlow.value, viewModel.stateFlow.value,
) )
verify(exactly = 1) { verify(exactly = 1) {
settingsRepository.clearBiometricsKey() settingsRepository.clearBiometricsKey()
}
} }
}
@Suppress("MaxLineLength") @Suppress("MaxLineLength")
@Test @Test
@ -310,11 +333,28 @@ class SetupUnlockViewModelTest : BaseViewModelTest() {
) )
} }
@Test
fun `CloseClick action should send NavigateBack event`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SetupUnlockAction.CloseClick)
assertEquals(
SetupUnlockEvent.NavigateBack,
awaitItem(),
)
}
}
private fun createViewModel( private fun createViewModel(
state: SetupUnlockState? = null, state: SetupUnlockState? = null,
): SetupUnlockViewModel = ): SetupUnlockViewModel =
SetupUnlockViewModel( SetupUnlockViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to state)), savedStateHandle = SavedStateHandle(
mapOf(
"state" to state,
"isInitialSetup" to true,
),
),
authRepository = authRepository, authRepository = authRepository,
settingsRepository = settingsRepository, settingsRepository = settingsRepository,
biometricsEncryptionManager = biometricsEncryptionManager, biometricsEncryptionManager = biometricsEncryptionManager,
@ -328,29 +368,32 @@ private val DEFAULT_STATE: SetupUnlockState = SetupUnlockState(
isUnlockWithPasswordEnabled = true, isUnlockWithPasswordEnabled = true,
isUnlockWithBiometricsEnabled = false, isUnlockWithBiometricsEnabled = false,
dialogState = null, dialogState = null,
isInitialSetup = true,
)
private val DEFAULT_USER_ACCOUNT = UserState.Account(
userId = DEFAULT_USER_ID,
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.ACCOUNT_LOCK_SETUP,
) )
private val CIPHER = mockk<Cipher>() private val CIPHER = mockk<Cipher>()
private val DEFAULT_USER_STATE: UserState = UserState( private val DEFAULT_USER_STATE: UserState = UserState(
activeUserId = DEFAULT_USER_ID, activeUserId = DEFAULT_USER_ID,
accounts = listOf( accounts = listOf(
UserState.Account( DEFAULT_USER_ACCOUNT,
userId = DEFAULT_USER_ID,
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
hasMasterPassword = true,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
),
), ),
) )

View file

@ -231,7 +231,7 @@ class RootNavScreenTest : BaseComposeTest() {
RootNavState.OnboardingAccountLockSetup RootNavState.OnboardingAccountLockSetup
composeTestRule.runOnIdle { composeTestRule.runOnIdle {
fakeNavHostController.assertLastNavigation( fakeNavHostController.assertLastNavigation(
route = "setup_unlock", route = "setup_unlock_as_root/true",
navOptions = expectedNavOptions, navOptions = expectedNavOptions,
) )
} }

View file

@ -48,6 +48,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false private var onNavigateBackCalled = false
private var onNavigateToDeleteAccountCalled = false private var onNavigateToDeleteAccountCalled = false
private var onNavigateToPendingRequestsCalled = false private var onNavigateToPendingRequestsCalled = false
private var onNavigateToUnlockSetupScreenCalled = false
private val intentManager = mockk<IntentManager> { private val intentManager = mockk<IntentManager> {
every { launchUri(any()) } just runs every { launchUri(any()) } just runs
@ -85,6 +86,7 @@ class AccountSecurityScreenTest : BaseComposeTest() {
onNavigateBack = { onNavigateBackCalled = true }, onNavigateBack = { onNavigateBackCalled = true },
onNavigateToDeleteAccount = { onNavigateToDeleteAccountCalled = true }, onNavigateToDeleteAccount = { onNavigateToDeleteAccountCalled = true },
onNavigateToPendingRequests = { onNavigateToPendingRequestsCalled = true }, onNavigateToPendingRequests = { onNavigateToPendingRequestsCalled = true },
onNavigateToSetupUnlockScreen = { onNavigateToUnlockSetupScreenCalled = true },
viewModel = viewModel, viewModel = viewModel,
biometricsManager = biometricsManager, biometricsManager = biometricsManager,
intentManager = intentManager, intentManager = intentManager,
@ -1524,6 +1526,12 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.performClick() .performClick()
verify { viewModel.trySendAction(AccountSecurityAction.UnlockActionCardDismiss) } verify { viewModel.trySendAction(AccountSecurityAction.UnlockActionCardDismiss) }
} }
@Test
fun `on NavigateToSetupUnlockScreen event invokes the correct lambda`() {
mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateToSetupUnlockScreen)
assertTrue(onNavigateToUnlockSetupScreenCalled)
}
} }
private val CIPHER = mockk<Cipher>() private val CIPHER = mockk<Cipher>()

View file

@ -727,11 +727,19 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
} }
} }
@Suppress("MaxLineLength")
@Test @Test
fun `when UnlockActionCardCtaClick action received, should dismiss unlock action card`() { fun `when UnlockActionCardCtaClick action received, should dismiss unlock action card and send NavigateToSetupUnlockScreen event`() =
runTest {
mutableShowUnlockBadgeFlow.update { true } mutableShowUnlockBadgeFlow.update { true }
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.trySendAction(AccountSecurityAction.UnlockActionCardCtaClick) viewModel.eventFlow.test {
viewModel.trySendAction(AccountSecurityAction.UnlockActionCardCtaClick)
assertEquals(
AccountSecurityEvent.NavigateToSetupUnlockScreen,
awaitItem(),
)
}
verify { verify {
settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false) settingsRepository.storeShowUnlockSettingBadge(DEFAULT_STATE.userId, false)
} }