From f20e64e256541b129024875abeb462f23d588d18 Mon Sep 17 00:00:00 2001 From: Shannon Date: Tue, 30 Jul 2024 10:49:09 -0600 Subject: [PATCH 1/8] PM-10242 PM-10243 PM-10244 PM-10245 PM-10246: Welcome carousel --- .../data/auth/repository/AuthRepository.kt | 6 + .../auth/repository/AuthRepositoryImpl.kt | 9 + .../repository/di/AuthRepositoryModule.kt | 3 + .../ui/auth/feature/auth/AuthNavigation.kt | 10 +- .../auth/feature/welcome/WelcomeNavigation.kt | 32 +++ .../ui/auth/feature/welcome/WelcomeScreen.kt | 272 ++++++++++++++++++ .../auth/feature/welcome/WelcomeViewModel.kt | 161 +++++++++++ .../platform/feature/rootnav/RootNavScreen.kt | 3 + .../feature/rootnav/RootNavViewModel.kt | 139 +++++---- app/src/main/res/drawable/welcome_1.xml | 73 +++++ app/src/main/res/drawable/welcome_2.xml | 58 ++++ app/src/main/res/drawable/welcome_3.xml | 46 +++ app/src/main/res/drawable/welcome_4.xml | 56 ++++ app/src/main/res/values/strings.xml | 8 + .../auth/repository/AuthRepositoryTest.kt | 9 + .../auth/feature/welcome/WelcomeScreenTest.kt | 124 ++++++++ .../feature/welcome/WelcomeViewModelTest.kt | 95 ++++++ .../feature/rootnav/RootNavScreenTest.kt | 5 +- .../feature/rootnav/RootNavViewModelTest.kt | 35 ++- 19 files changed, 1076 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt create mode 100644 app/src/main/res/drawable/welcome_1.xml create mode 100644 app/src/main/res/drawable/welcome_2.xml create mode 100644 app/src/main/res/drawable/welcome_3.xml create mode 100644 app/src/main/res/drawable/welcome_4.xml create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModelTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index 2fe15b09c6e..fc775ff4eff 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -135,6 +135,12 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { */ fun clearPendingAccountDeletion() + /** + * Whether or not the welcome carousel should be displayed, based on the feature flag and + * whether the user has ever logged in or created an account before. + */ + suspend fun getShowWelcomeCarousel(): Boolean + /** * Attempt to delete the current account using the [masterPassword] and log them out * upon success. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 18c6eea4c99..cd7f5a7bcdb 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -77,6 +77,7 @@ import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.auth.util.toSdkParams +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager @@ -140,6 +141,7 @@ class AuthRepositoryImpl( private val trustedDeviceManager: TrustedDeviceManager, private val userLogoutManager: UserLogoutManager, private val policyManager: PolicyManager, + private val featureFlagManager: FeatureFlagManager, pushManager: PushManager, dispatcherManager: DispatcherManager, ) : AuthRepository, @@ -386,6 +388,13 @@ class AuthRepositoryImpl( mutableHasPendingAccountDeletionStateFlow.value = false } + override suspend fun getShowWelcomeCarousel(): Boolean = + !settingsRepository.hasUserLoggedInOrCreatedAccount && + featureFlagManager.getFeatureFlag( + key = FlagKey.OnboardingCarousel, + forceRefresh = false, + ) + override suspend fun deleteAccountWithMasterPassword( masterPassword: String, ): DeleteAccountResult { diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt index 495c57a39af..179d47b44c8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/di/AuthRepositoryModule.kt @@ -12,6 +12,7 @@ import com.x8bit.bitwarden.data.auth.manager.TrustedDeviceManager import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.AuthRepositoryImpl +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager @@ -52,6 +53,7 @@ object AuthRepositoryModule { userLogoutManager: UserLogoutManager, pushManager: PushManager, policyManager: PolicyManager, + featureFlagManager: FeatureFlagManager, ): AuthRepository = AuthRepositoryImpl( accountsService = accountsService, devicesService = devicesService, @@ -70,5 +72,6 @@ object AuthRepositoryModule { userLogoutManager = userLogoutManager, pushManager = pushManager, policyManager = policyManager, + featureFlagManager = featureFlagManager, ) } 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 060d550d82a..478a598920b 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 @@ -14,6 +14,7 @@ import com.x8bit.bitwarden.ui.auth.feature.environment.environmentDestination import com.x8bit.bitwarden.ui.auth.feature.environment.navigateToEnvironment 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.landing.navigateToLanding 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 @@ -25,6 +26,7 @@ import com.x8bit.bitwarden.ui.auth.feature.setpassword.navigateToSetPassword import com.x8bit.bitwarden.ui.auth.feature.setpassword.setPasswordDestination import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.navigateToTwoFactorLogin import com.x8bit.bitwarden.ui.auth.feature.twofactorlogin.twoFactorLoginDestination +import com.x8bit.bitwarden.ui.auth.feature.welcome.welcomeDestination const val AUTH_GRAPH_ROUTE: String = "auth_graph" @@ -32,7 +34,9 @@ const val AUTH_GRAPH_ROUTE: String = "auth_graph" * Add auth destinations to the nav graph. */ @Suppress("LongMethod") -fun NavGraphBuilder.authGraph(navController: NavHostController) { +fun NavGraphBuilder.authGraph( + navController: NavHostController, +) { navigation( startDestination = LANDING_ROUTE, route = AUTH_GRAPH_ROUTE, @@ -72,6 +76,10 @@ fun NavGraphBuilder.authGraph(navController: NavHostController) { navController.navigateToEnvironment() }, ) + welcomeDestination( + onNavigateToCreateAccount = { navController.navigateToCreateAccount() }, + onNavigateToLogin = { navController.navigateToLanding() }, + ) loginDestination( onNavigateBack = { navController.popBackStack() }, onNavigateToMasterPasswordHint = { emailAddress -> diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt new file mode 100644 index 00000000000..cac97f0af59 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import com.x8bit.bitwarden.ui.platform.base.util.composableWithStayTransitions + +const val WELCOME_ROUTE: String = "welcome" + +/** + * Navigate to the welcome screen. + */ +fun NavController.navigateToWelcome(navOptions: NavOptions? = null) { + this.navigate(WELCOME_ROUTE, navOptions) +} + +/** + * Add the Welcome screen to the nav graph. + */ +fun NavGraphBuilder.welcomeDestination( + onNavigateToCreateAccount: () -> Unit, + onNavigateToLogin: () -> Unit, +) { + composableWithStayTransitions( + route = WELCOME_ROUTE, + ) { + WelcomeScreen( + onNavigateToCreateAccount = onNavigateToCreateAccount, + onNavigateToLogin = onNavigateToLogin, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreen.kt new file mode 100644 index 00000000000..3a3fab133f5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreen.kt @@ -0,0 +1,272 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import android.content.res.Configuration +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +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.button.BitwardenFilledButton +import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton +import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold +import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter + +/** + * Top level composable for the welcome screen. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun WelcomeScreen( + onNavigateToCreateAccount: () -> Unit, + onNavigateToLogin: () -> Unit, + viewModel: WelcomeViewModel = hiltViewModel(), +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val pagerState = rememberPagerState(pageCount = { state.pages.size }) + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is WelcomeEvent.UpdatePager -> { + pagerState.animateScrollToPage(event.index) + } + + WelcomeEvent.NavigateToCreateAccount -> onNavigateToCreateAccount() + WelcomeEvent.NavigateToLogin -> onNavigateToLogin() + } + } + + BitwardenScaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + WelcomeScreenContent( + state = state, + pagerState = pagerState, + onPagerSwipe = remember(viewModel) { + { viewModel.trySendAction(WelcomeAction.PagerSwipe(it)) } + }, + onDotClick = remember(viewModel) { + { viewModel.trySendAction(WelcomeAction.DotClick(it)) } + }, + onCreateAccountClick = remember(viewModel) { + { viewModel.trySendAction(WelcomeAction.CreateAccountClick) } + }, + onLoginClick = remember(viewModel) { + { viewModel.trySendAction(WelcomeAction.LoginClick) } + }, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun WelcomeScreenContent( + state: WelcomeState, + pagerState: PagerState, + onPagerSwipe: (Int) -> Unit, + onDotClick: (Int) -> Unit, + onCreateAccountClick: () -> Unit, + onLoginClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + val horizontalPadding = if (isLandscape) 128.dp else 16.dp + + LaunchedEffect(pagerState.currentPage) { + onPagerSwipe(pagerState.currentPage) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.verticalScroll(rememberScrollState()), + ) { + Spacer(modifier = Modifier.weight(1f)) + + HorizontalPager(state = pagerState) { index -> + if (isLandscape) { + WelcomeCardLandscape( + state = state.pages[index], + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + } else { + WelcomeCardPortrait( + state = state.pages[index], + modifier = Modifier.padding(horizontal = horizontalPadding), + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + IndicatorDots( + selectedIndexProvider = { state.index }, + totalCount = state.pages.size, + onDotClick = onDotClick, + modifier = Modifier + .padding(bottom = 32.dp) + .height(44.dp), + ) + + BitwardenFilledButton( + label = stringResource(id = R.string.create_account), + onClick = onCreateAccountClick, + modifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth(), + ) + + BitwardenTextButton( + label = stringResource(id = R.string.log_in), + onClick = onLoginClick, + modifier = Modifier + .padding(horizontal = horizontalPadding) + .padding(bottom = 32.dp), + ) + + Spacer(modifier = Modifier.navigationBarsPadding()) + } +} + +@Composable +private fun WelcomeCardLandscape( + state: WelcomeState.WelcomeCard, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Image( + painter = rememberVectorPainter(id = state.imageRes), + contentDescription = null, + modifier = Modifier.size(132.dp), + ) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(start = 40.dp), + ) { + Text( + text = stringResource(id = state.titleRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 16.dp), + ) + + Text( + text = stringResource(id = state.messageRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun WelcomeCardPortrait( + state: WelcomeState.WelcomeCard, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Image( + painter = rememberVectorPainter(id = state.imageRes), + contentDescription = null, + modifier = Modifier.size(200.dp), + ) + + Text( + text = stringResource(id = state.titleRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding( + top = 48.dp, + bottom = 16.dp, + ), + ) + + Text( + text = stringResource(id = state.messageRes), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun IndicatorDots( + selectedIndexProvider: () -> Int, + totalCount: Int, + onDotClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + items(totalCount) { index -> + val color = animateColorAsState( + targetValue = if (index == selectedIndexProvider()) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + }, + label = "dotColor", + ) + + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(color.value) + .clickable { onDotClick(index) }, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt new file mode 100644 index 00000000000..19f494cd1f1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt @@ -0,0 +1,161 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import android.os.Parcelable +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +/** + * Manages application state for the welcome screen. + */ +@HiltViewModel +class WelcomeViewModel @Inject constructor() : + BaseViewModel( + initialState = WelcomeState( + index = 0, + pages = listOf( + WelcomeState.WelcomeCard.CardOne, + WelcomeState.WelcomeCard.CardTwo, + WelcomeState.WelcomeCard.CardThree, + WelcomeState.WelcomeCard.CardFour, + ), + ), + ) { + override fun handleAction(action: WelcomeAction) { + when (action) { + is WelcomeAction.PagerSwipe -> handlePagerSwipe(action) + is WelcomeAction.DotClick -> handleDotClick(action) + WelcomeAction.CreateAccountClick -> handleCreateAccountClick() + WelcomeAction.LoginClick -> handleLoginClick() + } + } + + private fun handlePagerSwipe(action: WelcomeAction.PagerSwipe) { + mutableStateFlow.update { it.copy(index = action.index) } + } + + private fun handleDotClick(action: WelcomeAction.DotClick) { + mutableStateFlow.update { it.copy(index = action.index) } + sendEvent(WelcomeEvent.UpdatePager(index = action.index)) + } + + private fun handleCreateAccountClick() { + sendEvent(WelcomeEvent.NavigateToCreateAccount) + } + + private fun handleLoginClick() { + sendEvent(WelcomeEvent.NavigateToLogin) + } +} + +/** + * Models state of the welcome screen. + */ +@Parcelize +data class WelcomeState( + val index: Int, + val pages: List, +) : Parcelable { + /** + * A sealed class to represent the different cards the user can view on the welcome screen. + */ + sealed class WelcomeCard : Parcelable { + abstract val imageRes: Int + abstract val titleRes: Int + abstract val messageRes: Int + + /** + * Represents the first card the user should see on the welcome screen. + */ + @Parcelize + data object CardOne : WelcomeCard() { + override val imageRes: Int = R.drawable.welcome_1 + override val titleRes: Int = R.string.welcome_title_1 + override val messageRes: Int = R.string.welcome_message_1 + } + + /** + * Represents the second card the user should see on the welcome screen. + */ + @Parcelize + data object CardTwo : WelcomeCard() { + override val imageRes: Int = R.drawable.welcome_2 + override val titleRes: Int = R.string.welcome_title_2 + override val messageRes: Int = R.string.welcome_message_2 + } + + /** + * Represents the third card the user should see on the welcome screen. + */ + @Parcelize + data object CardThree : WelcomeCard() { + override val imageRes: Int = R.drawable.welcome_3 + override val titleRes: Int = R.string.welcome_title_3 + override val messageRes: Int = R.string.welcome_message_3 + } + + /** + * Represents the fourth card the user should see on the welcome screen. + */ + @Parcelize + data object CardFour : WelcomeCard() { + override val imageRes: Int = R.drawable.welcome_4 + override val titleRes: Int = R.string.welcome_title_4 + override val messageRes: Int = R.string.welcome_message_4 + } + } +} + +/** + * Models events for the welcome screen. + */ +sealed class WelcomeEvent { + /** + * Updates the current index of the pager. + */ + data class UpdatePager( + val index: Int, + ) : WelcomeEvent() + + /** + * Navigates to the create account screen. + */ + data object NavigateToCreateAccount : WelcomeEvent() + + /** + * Navigates to the login screen. + */ + data object NavigateToLogin : WelcomeEvent() +} + +/** + * Models actions for the welcome screen. + */ +sealed class WelcomeAction { + /** + * Swipe the pager to the given [index]. + */ + data class PagerSwipe( + val index: Int, + ) : WelcomeAction() + + /** + * Click one of the page indicator dots at the given [index]. + */ + data class DotClick( + val index: Int, + ) : WelcomeAction() + + /** + * Click the create account button. + */ + data object CreateAccountClick : WelcomeAction() + + /** + * Click the login button. + */ + data object LoginClick : WelcomeAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index 22f2d72d72b..be6afc168cd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -29,6 +29,7 @@ import com.x8bit.bitwarden.ui.auth.feature.trusteddevice.trustedDeviceGraph import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestination +import com.x8bit.bitwarden.ui.auth.feature.welcome.navigateToWelcome import com.x8bit.bitwarden.ui.platform.feature.rootnav.util.toVaultItemListingType import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.loginapproval.navigateToLoginApproval import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE @@ -85,6 +86,7 @@ fun RootNavScreen( val targetRoute = when (state) { RootNavState.Auth -> AUTH_GRAPH_ROUTE + RootNavState.AuthWithWelcome -> AUTH_GRAPH_ROUTE RootNavState.ResetPassword -> RESET_PASSWORD_ROUTE RootNavState.SetPassword -> SET_PASSWORD_ROUTE RootNavState.Splash -> SPLASH_ROUTE @@ -133,6 +135,7 @@ fun RootNavScreen( LaunchedEffect(state) { when (val currentState = state) { RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) + RootNavState.AuthWithWelcome -> navController.navigateToWelcome(rootNavOptions) RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions) RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index 5e05fab38c5..c9ac502c2fd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -25,7 +26,7 @@ import javax.inject.Inject */ @HiltViewModel class RootNavViewModel @Inject constructor( - authRepository: AuthRepository, + private val authRepository: AuthRepository, specialCircumstanceManager: SpecialCircumstanceManager, ) : BaseViewModel( initialState = RootNavState.Splash, @@ -52,76 +53,84 @@ class RootNavViewModel @Inject constructor( } } - @Suppress("CyclomaticComplexMethod", "MaxLineLength") + @Suppress("CyclomaticComplexMethod", "MaxLineLength", "LongMethod") private fun handleUserStateUpdateReceive( action: RootNavAction.Internal.UserStateUpdateReceive, ) { - val userState = action.userState - val updatedRootNavState = when { - userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false && - !userState.activeAccount.isVaultUnlocked && - !userState.activeAccount.hasManualUnlockMechanism -> RootNavState.TrustedDevice - - userState?.activeAccount?.needsMasterPassword == true -> RootNavState.SetPassword - - userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword - - userState == null || - !userState.activeAccount.isLoggedIn || - userState.hasPendingAccountAddition -> RootNavState.Auth - - userState.activeAccount.isVaultUnlocked -> { - when (val specialCircumstance = action.specialCircumstance) { - is SpecialCircumstance.AutofillSave -> { - RootNavState.VaultUnlockedForAutofillSave( - autofillSaveItem = specialCircumstance.autofillSaveItem, - ) - } - - is SpecialCircumstance.AutofillSelection -> { - RootNavState.VaultUnlockedForAutofillSelection( - activeUserId = userState.activeAccount.userId, - type = specialCircumstance.autofillSelectionData.type, - ) - } - - is SpecialCircumstance.ShareNewSend -> RootNavState.VaultUnlockedForNewSend - - is SpecialCircumstance.PasswordlessRequest -> { - RootNavState.VaultUnlockedForAuthRequest - } - - is SpecialCircumstance.Fido2Save -> { - RootNavState.VaultUnlockedForFido2Save( - activeUserId = userState.activeUserId, - fido2CredentialRequest = specialCircumstance.fido2CredentialRequest, - ) - } - - is SpecialCircumstance.Fido2Assertion -> { - RootNavState.VaultUnlockedForFido2Assertion( - activeUserId = userState.activeUserId, - fido2CredentialAssertionRequest = specialCircumstance.fido2AssertionRequest, - ) + viewModelScope.launch { + val userState = action.userState + val updatedRootNavState = when { + userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false && + !userState.activeAccount.isVaultUnlocked && + !userState.activeAccount.hasManualUnlockMechanism -> RootNavState.TrustedDevice + + userState?.activeAccount?.needsMasterPassword == true -> RootNavState.SetPassword + + userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword + + userState == null || + !userState.activeAccount.isLoggedIn || + userState.hasPendingAccountAddition -> { + if (authRepository.getShowWelcomeCarousel()) { + RootNavState.AuthWithWelcome + } else { + RootNavState.Auth } + } - is SpecialCircumstance.Fido2GetCredentials -> { - RootNavState.VaultUnlockedForFido2GetCredentials( - activeUserId = userState.activeUserId, - fido2GetCredentialsRequest = specialCircumstance.fido2GetCredentialsRequest, - ) + userState.activeAccount.isVaultUnlocked -> { + when (val specialCircumstance = action.specialCircumstance) { + is SpecialCircumstance.AutofillSave -> { + RootNavState.VaultUnlockedForAutofillSave( + autofillSaveItem = specialCircumstance.autofillSaveItem, + ) + } + + is SpecialCircumstance.AutofillSelection -> { + RootNavState.VaultUnlockedForAutofillSelection( + activeUserId = userState.activeAccount.userId, + type = specialCircumstance.autofillSelectionData.type, + ) + } + + is SpecialCircumstance.ShareNewSend -> RootNavState.VaultUnlockedForNewSend + + is SpecialCircumstance.PasswordlessRequest -> { + RootNavState.VaultUnlockedForAuthRequest + } + + is SpecialCircumstance.Fido2Save -> { + RootNavState.VaultUnlockedForFido2Save( + activeUserId = userState.activeUserId, + fido2CredentialRequest = specialCircumstance.fido2CredentialRequest, + ) + } + + is SpecialCircumstance.Fido2Assertion -> { + RootNavState.VaultUnlockedForFido2Assertion( + activeUserId = userState.activeUserId, + fido2CredentialAssertionRequest = specialCircumstance.fido2AssertionRequest, + ) + } + + is SpecialCircumstance.Fido2GetCredentials -> { + RootNavState.VaultUnlockedForFido2GetCredentials( + activeUserId = userState.activeUserId, + fido2GetCredentialsRequest = specialCircumstance.fido2GetCredentialsRequest, + ) + } + + SpecialCircumstance.GeneratorShortcut, + SpecialCircumstance.VaultShortcut, + null, + -> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId) } - - SpecialCircumstance.GeneratorShortcut, - SpecialCircumstance.VaultShortcut, - null, - -> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId) } - } - else -> RootNavState.VaultLocked + else -> RootNavState.VaultLocked + } + mutableStateFlow.update { updatedRootNavState } } - mutableStateFlow.update { updatedRootNavState } } } @@ -135,6 +144,12 @@ sealed class RootNavState : Parcelable { @Parcelize data object Auth : RootNavState() + /** + * App should show auth nav graph starting with the welcome carousel. + */ + @Parcelize + data object AuthWithWelcome : RootNavState() + /** * App should show reset password graph. */ diff --git a/app/src/main/res/drawable/welcome_1.xml b/app/src/main/res/drawable/welcome_1.xml new file mode 100644 index 00000000000..a8050d264f6 --- /dev/null +++ b/app/src/main/res/drawable/welcome_1.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/welcome_2.xml b/app/src/main/res/drawable/welcome_2.xml new file mode 100644 index 00000000000..a9607bd74ac --- /dev/null +++ b/app/src/main/res/drawable/welcome_2.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/welcome_3.xml b/app/src/main/res/drawable/welcome_3.xml new file mode 100644 index 00000000000..a7486fce91b --- /dev/null +++ b/app/src/main/res/drawable/welcome_3.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/welcome_4.xml b/app/src/main/res/drawable/welcome_4.xml new file mode 100644 index 00000000000..ef969822153 --- /dev/null +++ b/app/src/main/res/drawable/welcome_4.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b2f7fca1d5f..06d3679f60e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -927,4 +927,12 @@ Do you want to switch to this account? Self-hosted server URL Passkey operation failed because user could not be verified. User verification + Privacy, prioritized + Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you. + Never guess again + Set up biometric unlock and autofill to log into your accounts without typing a single letter. + Level up your logins + Use the generator to create and save strong, unique passwords for all your accounts. + Your data, when and where you need it + Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps. diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 730802028cd..48637e2dff8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -4504,6 +4504,15 @@ class AuthRepositoryTest { } } + @Test + fun `hasUserLoggedInOrCreatedAccount should return value from settings repository`() { + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true + assertTrue(repository.hasUserLoggedInOrCreatedAccount) + + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns false + assertFalse(repository.hasUserLoggedInOrCreatedAccount) + } + @Test fun `getOrganizationDomainSsoDetails Failure should return Failure `() = runTest { val email = "test@gmail.com" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreenTest.kt new file mode 100644 index 00000000000..9a4bd0ddbbe --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeScreenTest.kt @@ -0,0 +1,124 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.junit.Before +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertTrue +import org.robolectric.annotation.Config + +class WelcomeScreenTest : BaseComposeTest() { + private var onNavigateToCreateAccountCalled = false + private var onNavigateToLoginCalled = false + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val mutableEventFlow = bufferedMutableSharedFlow() + private val viewModel = mockk(relaxed = true) { + every { stateFlow } returns mutableStateFlow + every { eventFlow } returns mutableEventFlow + } + + @Before + fun setUp() { + composeTestRule.setContent { + WelcomeScreen( + onNavigateToCreateAccount = { onNavigateToCreateAccountCalled = true }, + onNavigateToLogin = { onNavigateToLoginCalled = true }, + viewModel = viewModel, + ) + } + } + + @Test + fun `pages should display and update according to state`() { + composeTestRule + .onNodeWithText("Privacy, prioritized") + .assertExists() + .assertIsDisplayed() + + mutableEventFlow.tryEmit(WelcomeEvent.UpdatePager(index = 1)) + composeTestRule + .onNodeWithText("Privacy, prioritized") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Never guess again") + .assertExists() + .assertIsDisplayed() + + mutableStateFlow.update { it.copy(pages = listOf(WelcomeState.WelcomeCard.CardThree)) } + composeTestRule + .onNodeWithText("Level up your logins") + .assertExists() + .assertIsDisplayed() + } + + @Config(qualifiers = "land") + @Test + fun `pages should display and update according to state in landscape mode`() { + composeTestRule + .onNodeWithText("Privacy, prioritized") + .assertExists() + .assertIsDisplayed() + + mutableEventFlow.tryEmit(WelcomeEvent.UpdatePager(index = 1)) + composeTestRule + .onNodeWithText("Privacy, prioritized") + .assertDoesNotExist() + composeTestRule + .onNodeWithText("Never guess again") + .assertExists() + .assertIsDisplayed() + + mutableStateFlow.update { it.copy(pages = listOf(WelcomeState.WelcomeCard.CardThree)) } + composeTestRule + .onNodeWithText("Level up your logins") + .assertExists() + .assertIsDisplayed() + } + + @Test + fun `NavigateToCreateAccount event should call onNavigateToCreateAccount`() { + mutableEventFlow.tryEmit(WelcomeEvent.NavigateToCreateAccount) + assertTrue(onNavigateToCreateAccountCalled) + } + + @Test + fun `NavigateToLogin event should call onNavigateToLogin`() { + mutableEventFlow.tryEmit(WelcomeEvent.NavigateToLogin) + assertTrue(onNavigateToLoginCalled) + } + + @Test + fun `create account button click should send CreateAccountClick action`() { + composeTestRule + .onNodeWithText("Create account") + .performClick() + verify { viewModel.trySendAction(WelcomeAction.CreateAccountClick) } + } + + @Test + fun `login button click should send LoginClick action`() { + // Use an empty list of pages to guarantee that the login button + // will be in view on the UI testing viewport. + mutableStateFlow.update { it.copy(pages = emptyList()) } + composeTestRule + .onNodeWithText("Log In") + .performClick() + verify { viewModel.trySendAction(WelcomeAction.LoginClick) } + } +} + +private val DEFAULT_STATE = WelcomeState( + index = 0, + pages = listOf( + WelcomeState.WelcomeCard.CardOne, + WelcomeState.WelcomeCard.CardTwo, + ), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModelTest.kt new file mode 100644 index 00000000000..f48e0af891b --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModelTest.kt @@ -0,0 +1,95 @@ +package com.x8bit.bitwarden.ui.auth.feature.welcome + +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 WelcomeViewModelTest : BaseViewModelTest() { + @Test + fun `initial state should be correct`() = runTest { + val viewModel = WelcomeViewModel() + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE, + awaitItem(), + ) + } + } + + @Test + fun `PagerSwipe should update state`() = runTest { + val viewModel = WelcomeViewModel() + val newIndex = 2 + + viewModel.trySendAction(WelcomeAction.PagerSwipe(index = newIndex)) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(index = newIndex), + awaitItem(), + ) + } + } + + @Test + fun `DotClick should update state and emit UpdatePager`() = runTest { + val viewModel = WelcomeViewModel() + val newIndex = 2 + + viewModel.trySendAction(WelcomeAction.DotClick(index = newIndex)) + + viewModel.stateFlow.test { + assertEquals( + DEFAULT_STATE.copy(index = newIndex), + awaitItem(), + ) + } + viewModel.eventFlow.test { + assertEquals( + WelcomeEvent.UpdatePager(index = newIndex), + awaitItem(), + ) + } + } + + @Test + fun `CreateAccountClick should emit NavigateToCreateAccount`() = runTest { + val viewModel = WelcomeViewModel() + + viewModel.trySendAction(WelcomeAction.CreateAccountClick) + + viewModel.eventFlow.test { + assertEquals( + WelcomeEvent.NavigateToCreateAccount, + awaitItem(), + ) + } + } + + @Test + fun `LoginClick should emit NavigateToLogin`() = runTest { + val viewModel = WelcomeViewModel() + + viewModel.trySendAction(WelcomeAction.LoginClick) + + viewModel.eventFlow.test { + assertEquals( + WelcomeEvent.NavigateToLogin, + awaitItem(), + ) + } + } +} + +private val DEFAULT_STATE = WelcomeState( + index = 0, + pages = listOf( + WelcomeState.WelcomeCard.CardOne, + WelcomeState.WelcomeCard.CardTwo, + WelcomeState.WelcomeCard.CardThree, + WelcomeState.WelcomeCard.CardFour, + ), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index c524de67d84..32ef968432e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav import androidx.navigation.navOptions import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData +import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.FakeNavHostController import io.mockk.every @@ -64,7 +65,9 @@ class RootNavScreenTest : BaseComposeTest() { assertFalse(isSplashScreenRemoved) // Make sure navigating to Auth works as expected: - rootNavStateFlow.value = RootNavState.Auth + rootNavStateFlow.value = RootNavState.Auth( + startDestination = LANDING_ROUTE, + ) composeTestRule.runOnIdle { fakeNavHostController.assertLastNavigation( route = "auth_graph", diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 0b714557f55..d02e1082594 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -11,6 +11,8 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.model.Environment +import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE +import com.x8bit.bitwarden.ui.auth.feature.welcome.WELCOME_ROUTE import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every import io.mockk.mockk @@ -22,6 +24,7 @@ class RootNavViewModelTest : BaseViewModelTest() { private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow + every { hasUserLoggedInOrCreatedAccount } returns true } private val specialCircumstanceManager = SpecialCircumstanceManagerImpl() @@ -29,7 +32,22 @@ class RootNavViewModelTest : BaseViewModelTest() { fun `when there are no accounts the nav state should be Auth`() { mutableUserStateFlow.tryEmit(null) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth(startDestination = LANDING_ROUTE), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `when there are no accounts and the user has not logged on before the nav state should be Auth with start destination welcome`() { + every { authRepository.hasUserLoggedInOrCreatedAccount } returns false + mutableUserStateFlow.tryEmit(null) + val viewModel = createViewModel() + assertEquals( + RootNavState.Auth(startDestination = WELCOME_ROUTE), + viewModel.stateFlow.value, + ) } @Test @@ -57,7 +75,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth(startDestination = LANDING_ROUTE), + viewModel.stateFlow.value, + ) } @Test @@ -220,7 +241,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth(startDestination = LANDING_ROUTE), + viewModel.stateFlow.value, + ) } @Suppress("MaxLineLength") @@ -250,7 +274,10 @@ class RootNavViewModelTest : BaseViewModelTest() { ), ) val viewModel = createViewModel() - assertEquals(RootNavState.Auth, viewModel.stateFlow.value) + assertEquals( + RootNavState.Auth(startDestination = LANDING_ROUTE), + viewModel.stateFlow.value, + ) } @Test From 6d8a4a4e5a933ec45749401c8735053704ec0e93 Mon Sep 17 00:00:00 2001 From: Shannon Date: Tue, 6 Aug 2024 14:43:53 -0400 Subject: [PATCH 2/8] Feedback updates and added feature flag --- .../auth/repository/AuthRepositoryImpl.kt | 3 ++- .../platform/feature/rootnav/RootNavScreen.kt | 2 +- .../auth/repository/AuthRepositoryTest.kt | 25 ++++++++++++++++--- .../feature/rootnav/RootNavScreenTest.kt | 14 ++++++++--- .../feature/rootnav/RootNavViewModelTest.kt | 19 +++++++------- 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index cd7f5a7bcdb..caf4af24553 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -81,6 +81,7 @@ import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.util.getActivePolicies import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository @@ -392,7 +393,7 @@ class AuthRepositoryImpl( !settingsRepository.hasUserLoggedInOrCreatedAccount && featureFlagManager.getFeatureFlag( key = FlagKey.OnboardingCarousel, - forceRefresh = false, + forceRefresh = true, ) override suspend fun deleteAccountWithMasterPassword( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index be6afc168cd..312d3dfec01 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -135,7 +135,7 @@ fun RootNavScreen( LaunchedEffect(state) { when (val currentState = state) { RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) - RootNavState.AuthWithWelcome -> navController.navigateToWelcome(rootNavOptions) + RootNavState.AuthWithWelcome -> navController.navigateToWelcome(rootNavOptions) RootNavState.ResetPassword -> navController.navigateToResetPasswordGraph(rootNavOptions) RootNavState.SetPassword -> navController.navigateToSetPassword(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 48637e2dff8..4156fce2128 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -84,9 +84,11 @@ import com.x8bit.bitwarden.data.auth.repository.util.toUserState import com.x8bit.bitwarden.data.auth.util.YubiKeyResult import com.x8bit.bitwarden.data.auth.util.toSdkParams import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.PolicyManager import com.x8bit.bitwarden.data.platform.manager.PushManager import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.manager.model.FlagKey import com.x8bit.bitwarden.data.platform.manager.model.NotificationLogoutData import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.Environment @@ -217,6 +219,7 @@ class AuthRepositoryTest { getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD) } returns mutableActivePolicyFlow } + private val featureFlagManager: FeatureFlagManager = mockk() private val repository = AuthRepositoryImpl( accountsService = accountsService, @@ -236,6 +239,7 @@ class AuthRepositoryTest { dispatcherManager = dispatcherManager, pushManager = pushManager, policyManager = policyManager, + featureFlagManager = featureFlagManager, ) @BeforeEach @@ -4504,13 +4508,26 @@ class AuthRepositoryTest { } } + @Suppress("MaxLineLength") @Test - fun `hasUserLoggedInOrCreatedAccount should return value from settings repository`() { + fun `getShowWelcomeCarousel should return value from settings repository and feature flag manager`() = runTest { + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns false + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, true) + } returns true + assertTrue(repository.getShowWelcomeCarousel()) + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true - assertTrue(repository.hasUserLoggedInOrCreatedAccount) + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, true) + } returns true + assertFalse(repository.getShowWelcomeCarousel()) - every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns false - assertFalse(repository.hasUserLoggedInOrCreatedAccount) + every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true + coEvery { + featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, true) + } returns false + assertFalse(repository.getShowWelcomeCarousel()) } @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 32ef968432e..dc5c89ab95f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -2,7 +2,6 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav import androidx.navigation.navOptions import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData -import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.FakeNavHostController import io.mockk.every @@ -65,9 +64,7 @@ class RootNavScreenTest : BaseComposeTest() { assertFalse(isSplashScreenRemoved) // Make sure navigating to Auth works as expected: - rootNavStateFlow.value = RootNavState.Auth( - startDestination = LANDING_ROUTE, - ) + rootNavStateFlow.value = RootNavState.Auth composeTestRule.runOnIdle { fakeNavHostController.assertLastNavigation( route = "auth_graph", @@ -76,6 +73,15 @@ class RootNavScreenTest : BaseComposeTest() { } assertTrue(isSplashScreenRemoved) + // Make sure navigating to Auth with the welcome route works as expected: + rootNavStateFlow.value = RootNavState.AuthWithWelcome + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "welcome", + navOptions = expectedNavOptions, + ) + } + // Make sure navigating to vault locked works as expected: rootNavStateFlow.value = RootNavState.VaultLocked composeTestRule.runOnIdle { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index d02e1082594..393e6ca4a0b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -11,9 +11,8 @@ import com.x8bit.bitwarden.data.autofill.model.AutofillSelectionData import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.model.Environment -import com.x8bit.bitwarden.ui.auth.feature.landing.LANDING_ROUTE -import com.x8bit.bitwarden.ui.auth.feature.welcome.WELCOME_ROUTE import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow @@ -24,7 +23,7 @@ class RootNavViewModelTest : BaseViewModelTest() { private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow - every { hasUserLoggedInOrCreatedAccount } returns true + coEvery { getShowWelcomeCarousel() } returns false } private val specialCircumstanceManager = SpecialCircumstanceManagerImpl() @@ -33,19 +32,19 @@ class RootNavViewModelTest : BaseViewModelTest() { mutableUserStateFlow.tryEmit(null) val viewModel = createViewModel() assertEquals( - RootNavState.Auth(startDestination = LANDING_ROUTE), + RootNavState.Auth, viewModel.stateFlow.value, ) } @Suppress("MaxLineLength") @Test - fun `when there are no accounts and the user has not logged on before the nav state should be Auth with start destination welcome`() { - every { authRepository.hasUserLoggedInOrCreatedAccount } returns false + fun `when there are no accounts and the user has not logged on before the nav state should be Auth with the welcome route`() { + coEvery { authRepository.getShowWelcomeCarousel() } returns true mutableUserStateFlow.tryEmit(null) val viewModel = createViewModel() assertEquals( - RootNavState.Auth(startDestination = WELCOME_ROUTE), + RootNavState.AuthWithWelcome, viewModel.stateFlow.value, ) } @@ -76,7 +75,7 @@ class RootNavViewModelTest : BaseViewModelTest() { ) val viewModel = createViewModel() assertEquals( - RootNavState.Auth(startDestination = LANDING_ROUTE), + RootNavState.Auth, viewModel.stateFlow.value, ) } @@ -242,7 +241,7 @@ class RootNavViewModelTest : BaseViewModelTest() { ) val viewModel = createViewModel() assertEquals( - RootNavState.Auth(startDestination = LANDING_ROUTE), + RootNavState.Auth, viewModel.stateFlow.value, ) } @@ -275,7 +274,7 @@ class RootNavViewModelTest : BaseViewModelTest() { ) val viewModel = createViewModel() assertEquals( - RootNavState.Auth(startDestination = LANDING_ROUTE), + RootNavState.Auth, viewModel.stateFlow.value, ) } From a246689d4716b774644fbb2c587d1f08d3929ed7 Mon Sep 17 00:00:00 2001 From: Shannon Date: Tue, 6 Aug 2024 15:51:18 -0400 Subject: [PATCH 3/8] Update --- .../bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt index cac97f0af59..ca0fcfb5bba 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeNavigation.kt @@ -5,7 +5,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import com.x8bit.bitwarden.ui.platform.base.util.composableWithStayTransitions -const val WELCOME_ROUTE: String = "welcome" +private const val WELCOME_ROUTE: String = "welcome" /** * Navigate to the welcome screen. From 95b7cdf51d4d85376b63f6e0043cc698454a9d16 Mon Sep 17 00:00:00 2001 From: Shannon Date: Wed, 7 Aug 2024 10:54:25 -0400 Subject: [PATCH 4/8] Don't force refresh --- .../bitwarden/data/auth/repository/AuthRepositoryImpl.kt | 2 +- .../bitwarden/data/auth/repository/AuthRepositoryTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index caf4af24553..13151dba0ec 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -393,7 +393,7 @@ class AuthRepositoryImpl( !settingsRepository.hasUserLoggedInOrCreatedAccount && featureFlagManager.getFeatureFlag( key = FlagKey.OnboardingCarousel, - forceRefresh = true, + forceRefresh = false, ) override suspend fun deleteAccountWithMasterPassword( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 4156fce2128..1bdb27596fe 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -4513,19 +4513,19 @@ class AuthRepositoryTest { fun `getShowWelcomeCarousel should return value from settings repository and feature flag manager`() = runTest { every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns false coEvery { - featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, true) + featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, false) } returns true assertTrue(repository.getShowWelcomeCarousel()) every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true coEvery { - featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, true) + featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, false) } returns true assertFalse(repository.getShowWelcomeCarousel()) every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true coEvery { - featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, true) + featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, false) } returns false assertFalse(repository.getShowWelcomeCarousel()) } From b73bcbf6a67e9cc625cb88b277199034265e0f75 Mon Sep 17 00:00:00 2001 From: Shannon Date: Wed, 7 Aug 2024 11:32:32 -0400 Subject: [PATCH 5/8] Use synchronous feature flag --- .../data/auth/repository/AuthRepository.kt | 10 +- .../auth/repository/AuthRepositoryImpl.kt | 11 +- .../feature/rootnav/RootNavViewModel.kt | 135 +++++++++--------- .../auth/repository/AuthRepositoryTest.kt | 20 +-- .../feature/rootnav/RootNavViewModelTest.kt | 4 +- 5 files changed, 83 insertions(+), 97 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index fc775ff4eff..ac20b13f7c4 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -131,15 +131,15 @@ interface AuthRepository : AuthenticatorProvider, AuthRequestManager { val organizations: List /** - * Clears the pending deletion state that occurs when the an account is successfully deleted. + * Whether or not the welcome carousel should be displayed, based on the feature flag and + * whether the user has ever logged in or created an account before. */ - fun clearPendingAccountDeletion() + val showWelcomeCarousel: Boolean /** - * Whether or not the welcome carousel should be displayed, based on the feature flag and - * whether the user has ever logged in or created an account before. + * Clears the pending deletion state that occurs when the an account is successfully deleted. */ - suspend fun getShowWelcomeCarousel(): Boolean + fun clearPendingAccountDeletion() /** * Attempt to delete the current account using the [masterPassword] and log them out diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 13151dba0ec..a020da0315c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -321,6 +321,10 @@ class AuthRepositoryImpl( override val organizations: List get() = activeUserId?.let { authDiskSource.getOrganizations(it) }.orEmpty() + override val showWelcomeCarousel: Boolean + get() = !settingsRepository.hasUserLoggedInOrCreatedAccount && + featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel) + init { pushManager .syncOrgKeysFlow @@ -389,13 +393,6 @@ class AuthRepositoryImpl( mutableHasPendingAccountDeletionStateFlow.value = false } - override suspend fun getShowWelcomeCarousel(): Boolean = - !settingsRepository.hasUserLoggedInOrCreatedAccount && - featureFlagManager.getFeatureFlag( - key = FlagKey.OnboardingCarousel, - forceRefresh = false, - ) - override suspend fun deleteAccountWithMasterPassword( masterPassword: String, ): DeleteAccountResult { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index c9ac502c2fd..ca1a6821d0e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -57,80 +56,78 @@ class RootNavViewModel @Inject constructor( private fun handleUserStateUpdateReceive( action: RootNavAction.Internal.UserStateUpdateReceive, ) { - viewModelScope.launch { - val userState = action.userState - val updatedRootNavState = when { - userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false && - !userState.activeAccount.isVaultUnlocked && - !userState.activeAccount.hasManualUnlockMechanism -> RootNavState.TrustedDevice - - userState?.activeAccount?.needsMasterPassword == true -> RootNavState.SetPassword - - userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword - - userState == null || - !userState.activeAccount.isLoggedIn || - userState.hasPendingAccountAddition -> { - if (authRepository.getShowWelcomeCarousel()) { - RootNavState.AuthWithWelcome - } else { - RootNavState.Auth - } + val userState = action.userState + val updatedRootNavState = when { + userState?.activeAccount?.trustedDevice?.isDeviceTrusted == false && + !userState.activeAccount.isVaultUnlocked && + !userState.activeAccount.hasManualUnlockMechanism -> RootNavState.TrustedDevice + + userState?.activeAccount?.needsMasterPassword == true -> RootNavState.SetPassword + + userState?.activeAccount?.needsPasswordReset == true -> RootNavState.ResetPassword + + userState == null || + !userState.activeAccount.isLoggedIn || + userState.hasPendingAccountAddition -> { + if (authRepository.showWelcomeCarousel) { + RootNavState.AuthWithWelcome + } else { + RootNavState.Auth } + } - userState.activeAccount.isVaultUnlocked -> { - when (val specialCircumstance = action.specialCircumstance) { - is SpecialCircumstance.AutofillSave -> { - RootNavState.VaultUnlockedForAutofillSave( - autofillSaveItem = specialCircumstance.autofillSaveItem, - ) - } - - is SpecialCircumstance.AutofillSelection -> { - RootNavState.VaultUnlockedForAutofillSelection( - activeUserId = userState.activeAccount.userId, - type = specialCircumstance.autofillSelectionData.type, - ) - } - - is SpecialCircumstance.ShareNewSend -> RootNavState.VaultUnlockedForNewSend - - is SpecialCircumstance.PasswordlessRequest -> { - RootNavState.VaultUnlockedForAuthRequest - } - - is SpecialCircumstance.Fido2Save -> { - RootNavState.VaultUnlockedForFido2Save( - activeUserId = userState.activeUserId, - fido2CredentialRequest = specialCircumstance.fido2CredentialRequest, - ) - } - - is SpecialCircumstance.Fido2Assertion -> { - RootNavState.VaultUnlockedForFido2Assertion( - activeUserId = userState.activeUserId, - fido2CredentialAssertionRequest = specialCircumstance.fido2AssertionRequest, - ) - } - - is SpecialCircumstance.Fido2GetCredentials -> { - RootNavState.VaultUnlockedForFido2GetCredentials( - activeUserId = userState.activeUserId, - fido2GetCredentialsRequest = specialCircumstance.fido2GetCredentialsRequest, - ) - } - - SpecialCircumstance.GeneratorShortcut, - SpecialCircumstance.VaultShortcut, - null, - -> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId) + userState.activeAccount.isVaultUnlocked -> { + when (val specialCircumstance = action.specialCircumstance) { + is SpecialCircumstance.AutofillSave -> { + RootNavState.VaultUnlockedForAutofillSave( + autofillSaveItem = specialCircumstance.autofillSaveItem, + ) + } + + is SpecialCircumstance.AutofillSelection -> { + RootNavState.VaultUnlockedForAutofillSelection( + activeUserId = userState.activeAccount.userId, + type = specialCircumstance.autofillSelectionData.type, + ) + } + + is SpecialCircumstance.ShareNewSend -> RootNavState.VaultUnlockedForNewSend + + is SpecialCircumstance.PasswordlessRequest -> { + RootNavState.VaultUnlockedForAuthRequest } - } - else -> RootNavState.VaultLocked + is SpecialCircumstance.Fido2Save -> { + RootNavState.VaultUnlockedForFido2Save( + activeUserId = userState.activeUserId, + fido2CredentialRequest = specialCircumstance.fido2CredentialRequest, + ) + } + + is SpecialCircumstance.Fido2Assertion -> { + RootNavState.VaultUnlockedForFido2Assertion( + activeUserId = userState.activeUserId, + fido2CredentialAssertionRequest = specialCircumstance.fido2AssertionRequest, + ) + } + + is SpecialCircumstance.Fido2GetCredentials -> { + RootNavState.VaultUnlockedForFido2GetCredentials( + activeUserId = userState.activeUserId, + fido2GetCredentialsRequest = specialCircumstance.fido2GetCredentialsRequest, + ) + } + + SpecialCircumstance.GeneratorShortcut, + SpecialCircumstance.VaultShortcut, + null, + -> RootNavState.VaultUnlocked(activeUserId = userState.activeAccount.userId) + } } - mutableStateFlow.update { updatedRootNavState } + + else -> RootNavState.VaultLocked } + mutableStateFlow.update { updatedRootNavState } } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 1bdb27596fe..92cd8346921 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -4510,24 +4510,18 @@ class AuthRepositoryTest { @Suppress("MaxLineLength") @Test - fun `getShowWelcomeCarousel should return value from settings repository and feature flag manager`() = runTest { + fun `showWelcomeCarousel should return value from settings repository and feature flag manager`() { every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns false - coEvery { - featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, false) - } returns true - assertTrue(repository.getShowWelcomeCarousel()) + every { featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel) } returns true + assertTrue(repository.showWelcomeCarousel) every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true - coEvery { - featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, false) - } returns true - assertFalse(repository.getShowWelcomeCarousel()) + every { featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel) } returns true + assertFalse(repository.showWelcomeCarousel) every { settingsRepository.hasUserLoggedInOrCreatedAccount } returns true - coEvery { - featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel, false) - } returns false - assertFalse(repository.getShowWelcomeCarousel()) + every { featureFlagManager.getFeatureFlag(FlagKey.OnboardingCarousel) } returns false + assertFalse(repository.showWelcomeCarousel) } @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index 393e6ca4a0b..e879b020c98 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -12,7 +12,6 @@ import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManagerImpl import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest -import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow @@ -23,7 +22,6 @@ class RootNavViewModelTest : BaseViewModelTest() { private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow - coEvery { getShowWelcomeCarousel() } returns false } private val specialCircumstanceManager = SpecialCircumstanceManagerImpl() @@ -40,7 +38,7 @@ class RootNavViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test fun `when there are no accounts and the user has not logged on before the nav state should be Auth with the welcome route`() { - coEvery { authRepository.getShowWelcomeCarousel() } returns true + every { authRepository.showWelcomeCarousel } returns true mutableUserStateFlow.tryEmit(null) val viewModel = createViewModel() assertEquals( From f518beaabc89c6776a087293cd785f5be0228463 Mon Sep 17 00:00:00 2001 From: Shannon Date: Wed, 7 Aug 2024 12:41:00 -0400 Subject: [PATCH 6/8] Fix test --- .../ui/platform/feature/rootnav/RootNavViewModelTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index e879b020c98..b9fac81fa99 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -22,6 +22,7 @@ class RootNavViewModelTest : BaseViewModelTest() { private val mutableUserStateFlow = MutableStateFlow(null) private val authRepository = mockk { every { userStateFlow } returns mutableUserStateFlow + every { showWelcomeCarousel } returns false } private val specialCircumstanceManager = SpecialCircumstanceManagerImpl() From 61c5bdb9323dc7b3f7954fac4e3274b3dd0ff4ed Mon Sep 17 00:00:00 2001 From: Shannon Date: Wed, 7 Aug 2024 15:40:06 -0400 Subject: [PATCH 7/8] Rename title string resources --- .../bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt | 8 ++++---- app/src/main/res/values/strings.xml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt index 19f494cd1f1..3eb5d6ff484 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt @@ -73,7 +73,7 @@ data class WelcomeState( @Parcelize data object CardOne : WelcomeCard() { override val imageRes: Int = R.drawable.welcome_1 - override val titleRes: Int = R.string.welcome_title_1 + override val titleRes: Int = R.string.privacy_protected override val messageRes: Int = R.string.welcome_message_1 } @@ -83,7 +83,7 @@ data class WelcomeState( @Parcelize data object CardTwo : WelcomeCard() { override val imageRes: Int = R.drawable.welcome_2 - override val titleRes: Int = R.string.welcome_title_2 + override val titleRes: Int = R.string.never_guess_again override val messageRes: Int = R.string.welcome_message_2 } @@ -93,7 +93,7 @@ data class WelcomeState( @Parcelize data object CardThree : WelcomeCard() { override val imageRes: Int = R.drawable.welcome_3 - override val titleRes: Int = R.string.welcome_title_3 + override val titleRes: Int = R.string.level_up_your_logins override val messageRes: Int = R.string.welcome_message_3 } @@ -103,7 +103,7 @@ data class WelcomeState( @Parcelize data object CardFour : WelcomeCard() { override val imageRes: Int = R.drawable.welcome_4 - override val titleRes: Int = R.string.welcome_title_4 + override val titleRes: Int = R.string.your_data_when_and_where_you_need_it override val messageRes: Int = R.string.welcome_message_4 } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 06d3679f60e..ee2ac0cb0ff 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -927,12 +927,12 @@ Do you want to switch to this account? Self-hosted server URL Passkey operation failed because user could not be verified. User verification - Privacy, prioritized + Privacy, prioritized Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you. - Never guess again + Never guess again Set up biometric unlock and autofill to log into your accounts without typing a single letter. - Level up your logins + Level up your logins Use the generator to create and save strong, unique passwords for all your accounts. - Your data, when and where you need it + Your data, when and where you need it Save unlimited passwords across unlimited devices with Bitwarden mobile, browser, and desktop apps. From 27f33bf0eed44b874007d0e5fe20ad7bce2999b0 Mon Sep 17 00:00:00 2001 From: Shannon Date: Wed, 7 Aug 2024 15:49:49 -0400 Subject: [PATCH 8/8] I promise I'm literate... --- .../x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt index 3eb5d6ff484..41bd971f8b2 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/welcome/WelcomeViewModel.kt @@ -73,7 +73,7 @@ data class WelcomeState( @Parcelize data object CardOne : WelcomeCard() { override val imageRes: Int = R.drawable.welcome_1 - override val titleRes: Int = R.string.privacy_protected + override val titleRes: Int = R.string.privacy_prioritized override val messageRes: Int = R.string.welcome_message_1 } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ee2ac0cb0ff..492f0b4a22f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -927,7 +927,7 @@ Do you want to switch to this account? Self-hosted server URL Passkey operation failed because user could not be verified. User verification - Privacy, prioritized + Privacy, prioritized Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect what’s important to you. Never guess again Set up biometric unlock and autofill to log into your accounts without typing a single letter.