From aafd32fbc33c413acfe95a9b0127803a1fb60375 Mon Sep 17 00:00:00 2001 From: Andrew Haisting <142518658+ahaisting-livefront@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:55:46 -0500 Subject: [PATCH] BIT-896 Allow user to log out (#129) --- .../data/auth/repository/AuthRepository.kt | 5 ++ .../auth/repository/AuthRepositoryImpl.kt | 5 ++ .../ui/auth/feature/auth/AuthNavigation.kt | 2 +- .../platform/feature/rootnav/RootNavScreen.kt | 39 ++++++++- .../settings/AccountSecurityNavigation.kt | 26 ++++++ .../feature/settings/AccountSecurityScreen.kt | 83 +++++++++++++++++++ .../settings/AccountSecurityViewModel.kt | 46 ++++++++++ .../feature/settings/SettingsNavigation.kt | 26 ++++++ .../feature/settings/SettingsScreen.kt | 77 +++++++++++++++++ .../feature/settings/SettingsViewModel.kt | 41 +++++++++ .../vaultunlocked/VaultUnlockedNavigation.kt | 14 +++- .../VaultUnlockedNavBarNavigation.kt | 8 +- .../VaultUnlockedNavBarScreen.kt | 39 ++------- app/src/main/res/drawable/ic_back.xml | 9 ++ .../auth/repository/AuthRepositoryTest.kt | 25 ++++++ .../feature/rootnav/RootNavScreenTest.kt | 4 +- .../settings/AccountSecurityScreenTest.kt | 62 ++++++++++++++ .../settings/AccountSecurityViewModelTest.kt | 37 +++++++++ .../feature/settings/SettingsScreenTest.kt | 47 +++++++++++ .../feature/settings/SettingsViewModelTest.kt | 19 +++++ .../VaultUnlockedNavBarScreenTest.kt | 8 ++ 21 files changed, 581 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityViewModel.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityViewModelTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.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 452211532..dbcfb990b 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 @@ -36,6 +36,11 @@ interface AuthRepository { captchaToken: String?, ): LoginResult + /** + * Log out the current user. + */ + fun logout() + /** * Set the value of [captchaTokenResultFlow]. */ 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 1c0ecf934..1318339e7 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 @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import javax.inject.Inject import javax.inject.Singleton @@ -88,6 +89,10 @@ class AuthRepositoryImpl @Inject constructor( }, ) + override fun logout() { + mutableAuthStateFlow.update { AuthState.Unauthenticated } + } + override fun setCaptchaCallbackTokenResult(tokenResult: CaptchaCallbackTokenResult) { mutableCaptchaTokenFlow.tryEmit(tokenResult) } 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 3ad2b50f2..3998238a7 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 @@ -12,7 +12,7 @@ import com.x8bit.bitwarden.ui.auth.feature.landing.landingDestinations import com.x8bit.bitwarden.ui.auth.feature.login.loginDestinations import com.x8bit.bitwarden.ui.auth.feature.login.navigateToLogin -private const val AUTH_ROUTE: String = "auth" +const val AUTH_ROUTE: String = "auth" /** * Add auth destinations to the nav graph. 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 173fffadb..a34a56d8a 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 @@ -5,15 +5,18 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_ROUTE import com.x8bit.bitwarden.ui.auth.feature.auth.authDestinations import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuth import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.splash.navigateToSplash import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestinations +import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.VAULT_UNLOCKED_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.navigateToVaultUnlocked import com.x8bit.bitwarden.ui.platform.feature.vaultunlocked.vaultUnlockedDestinations @@ -39,7 +42,20 @@ fun RootNavScreen( ) { splashDestinations() authDestinations(navController) - vaultUnlockedDestinations() + vaultUnlockedDestinations(navController) + } + + val targetRoute = when (state) { + RootNavState.Auth -> AUTH_ROUTE + RootNavState.Splash -> SPLASH_ROUTE + RootNavState.VaultUnlocked -> VAULT_UNLOCKED_ROUTE + } + val currentRoute = navController.currentDestination?.rootLevelRoute() + + // Don't navigate if we are already at the correct root. This notably happens during process + // death. In this case, the NavHost already restores state, so we don't have to navigate. + if (currentRoute == targetRoute) { + return } // When state changes, navigate to different root navigation state @@ -47,10 +63,10 @@ fun RootNavScreen( // When changing root navigation state, pop everything else off the back stack: popUpTo(navController.graph.id) { inclusive = false - saveState = true + saveState = false } launchSingleTop = true - restoreState = true + restoreState = false } when (state) { @@ -59,3 +75,20 @@ fun RootNavScreen( RootNavState.VaultUnlocked -> navController.navigateToVaultUnlocked(rootNavOptions) } } + +/** + * Helper method that returns the highest level route for the given [NavDestination]. + * + * As noted above, this can be removed after upgrading to latest compose navigation, since + * the nav args can prevent us from having to do this check. + */ +@Suppress("ReturnCount") +private fun NavDestination?.rootLevelRoute(): String? { + if (this == null) { + return null + } + if (parent?.route == null) { + return route + } + return parent.rootLevelRoute() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityNavigation.kt new file mode 100644 index 000000000..eb9fe2326 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityNavigation.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +private const val ACCOUNT_SECURITY_ROUTE = "account_security" + +/** + * Add settings destinations to the nav graph. + */ +fun NavGraphBuilder.accountSecurityDestination( + onNavigateBack: () -> Unit, +) { + composable(ACCOUNT_SECURITY_ROUTE) { + AccountSecurityScreen(onNavigateBack = onNavigateBack) + } +} + +/** + * Navigate to the account security screen. + */ +fun NavController.navigateToAccountSecurity(navOptions: NavOptions? = null) { + navigate(ACCOUNT_SECURITY_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityScreen.kt new file mode 100644 index 000000000..907ad376e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityScreen.kt @@ -0,0 +1,83 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowTopAppBar + +/** + * Displays the account security screen. + */ +@Composable +fun AccountSecurityScreen( + onNavigateBack: () -> Unit, + viewModel: AccountSecurityViewModel = hiltViewModel(), +) { + EventsEffect(viewModel = viewModel) { event -> + when (event) { + AccountSecurityEvent.NavigateBack -> onNavigateBack.invoke() + } + } + Column( + Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surface), + ) { + BitwardenOverflowTopAppBar( + title = stringResource(id = R.string.account), + navigationIcon = painterResource(id = R.drawable.ic_back), + navigationIconContentDescription = stringResource(id = R.string.back), + onNavigationIconClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.BackClick) } + }, + dropdownMenuItemContent = {}, + ) + Spacer(Modifier.height(8.dp)) + AccountSecurityRow( + text = R.string.log_out.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.LogoutClick) } + }, + ) + } +} + +@Composable +private fun AccountSecurityRow( + text: Text, + onClick: () -> Unit, +) { + Text( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + .fillMaxWidth(), + text = text(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityViewModel.kt new file mode 100644 index 000000000..991bc137d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityViewModel.kt @@ -0,0 +1,46 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * View model for the account security screen. + */ +@HiltViewModel +class AccountSecurityViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : BaseViewModel( + initialState = Unit, +) { + override fun handleAction(action: AccountSecurityAction): Unit = when (action) { + AccountSecurityAction.LogoutClick -> authRepository.logout() + AccountSecurityAction.BackClick -> sendEvent(AccountSecurityEvent.NavigateBack) + } +} + +/** + * Models events for the account security screen. + */ +sealed class AccountSecurityEvent { + /** + * Navigate back. + */ + data object NavigateBack : AccountSecurityEvent() +} + +/** + * Models actions for the account security screen. + */ +sealed class AccountSecurityAction { + /** + * User clicked back button. + */ + data object BackClick : AccountSecurityAction() + + /** + * User clicked log out. + */ + data object LogoutClick : AccountSecurityAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt new file mode 100644 index 000000000..8f1e8979c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsNavigation.kt @@ -0,0 +1,26 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +const val SETTINGS_ROUTE: String = "settings" + +/** + * Add settings destinations to the nav graph. + */ +fun NavGraphBuilder.settingsDestinations( + onNavigateToAccountSecurity: () -> Unit, +) { + composable(SETTINGS_ROUTE) { + SettingsScreen(onNavigateToAccountSecurity = onNavigateToAccountSecurity) + } +} + +/** + * Navigate to the settings screen screen. + */ +fun NavController.navigateToSettings(navOptions: NavOptions? = null) { + navigate(SETTINGS_ROUTE, navOptions) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt new file mode 100644 index 000000000..a6cdeda7e --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreen.kt @@ -0,0 +1,77 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BitwardenMediumTopAppBar + +/** + * Displays the settings screen. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateToAccountSecurity: () -> Unit, + viewModel: SettingsViewModel = hiltViewModel(), +) { + EventsEffect(viewModel = viewModel) { event -> + when (event) { + SettingsEvent.NavigateAccountSecurity -> onNavigateToAccountSecurity.invoke() + } + } + Column( + Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surface), + ) { + BitwardenMediumTopAppBar( + title = stringResource(id = R.string.settings), + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), + ) + SettingsRow( + text = R.string.account.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(SettingsAction.AccountSecurityClick) } + }, + ) + } +} + +@Composable +private fun SettingsRow( + text: Text, + onClick: () -> Unit, +) { + Text( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(color = MaterialTheme.colorScheme.primary), + onClick = onClick, + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + .fillMaxWidth(), + text = text(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt new file mode 100644 index 000000000..d6645be08 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt @@ -0,0 +1,41 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * View model for the settings screen. + */ +@HiltViewModel +class SettingsViewModel @Inject constructor() : BaseViewModel( + initialState = Unit, +) { + override fun handleAction(action: SettingsAction): Unit = when (action) { + SettingsAction.AccountSecurityClick -> handleAccountSecurityClick() + } + + private fun handleAccountSecurityClick() { + sendEvent(SettingsEvent.NavigateAccountSecurity) + } +} + +/** + * Models events for the settings screen. + */ +sealed class SettingsEvent { + /** + * Navigate to the account security screen. + */ + data object NavigateAccountSecurity : SettingsEvent() +} + +/** + * Models actions for the settings screen. + */ +sealed class SettingsAction { + /** + * User clicked account security. + */ + data object AccountSecurityClick : SettingsAction() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index aa8e1d151..2ed3c5630 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -2,12 +2,15 @@ package com.x8bit.bitwarden.ui.platform.feature.vaultunlocked import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.navigation +import com.x8bit.bitwarden.ui.platform.feature.settings.accountSecurityDestination +import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToAccountSecurity import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.VAULT_UNLOCKED_NAV_BAR_ROUTE import com.x8bit.bitwarden.ui.platform.feature.vaultunlockednavbar.vaultUnlockedNavBarDestination -private const val VAULT_UNLOCKED_ROUTE: String = "VaultUnlocked" +const val VAULT_UNLOCKED_ROUTE: String = "VaultUnlocked" /** * Navigate to the vault unlocked screen. @@ -19,11 +22,16 @@ fun NavController.navigateToVaultUnlocked(navOptions: NavOptions? = null) { /** * Add vault unlocked destinations to the root nav graph. */ -fun NavGraphBuilder.vaultUnlockedDestinations() { +fun NavGraphBuilder.vaultUnlockedDestinations(navController: NavHostController) { navigation( startDestination = VAULT_UNLOCKED_NAV_BAR_ROUTE, route = VAULT_UNLOCKED_ROUTE, ) { - vaultUnlockedNavBarDestination() + vaultUnlockedNavBarDestination( + onNavigateToAccountSecurity = { navController.navigateToAccountSecurity() }, + ) + accountSecurityDestination( + onNavigateBack = { navController.popBackStack() }, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt index 826dc8d6c..6a2807d5f 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarNavigation.kt @@ -20,8 +20,12 @@ fun NavController.navigateToVaultUnlockedNavBar(navOptions: NavOptions? = null) /** * Add vault unlocked destination to the root nav graph. */ -fun NavGraphBuilder.vaultUnlockedNavBarDestination() { +fun NavGraphBuilder.vaultUnlockedNavBarDestination( + onNavigateToAccountSecurity: () -> Unit, +) { composable(VAULT_UNLOCKED_NAV_BAR_ROUTE) { - VaultUnlockedNavBarScreen() + VaultUnlockedNavBarScreen( + onNavigateToAccountSecurity = onNavigateToAccountSecurity, + ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt index 76b90920d..ea464b3b7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreen.kt @@ -28,6 +28,9 @@ import androidx.navigation.navOptions import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.components.PlaceholderComposable +import com.x8bit.bitwarden.ui.platform.feature.settings.SETTINGS_ROUTE +import com.x8bit.bitwarden.ui.platform.feature.settings.navigateToSettings +import com.x8bit.bitwarden.ui.platform.feature.settings.settingsDestinations import com.x8bit.bitwarden.ui.tools.feature.generator.GENERATOR_ROUTE import com.x8bit.bitwarden.ui.tools.feature.generator.generatorDestination import com.x8bit.bitwarden.ui.tools.feature.generator.navigateToGenerator @@ -41,6 +44,7 @@ import kotlinx.parcelize.Parcelize */ @Composable fun VaultUnlockedNavBarScreen( + onNavigateToAccountSecurity: () -> Unit, viewModel: VaultUnlockedNavBarViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), ) { @@ -68,6 +72,7 @@ fun VaultUnlockedNavBarScreen( } VaultUnlockedNavBarScaffold( navController = navController, + onNavigateToAccountSecurity = onNavigateToAccountSecurity, generatorTabClickedAction = { viewModel.trySendAction(VaultUnlockedNavBarAction.GeneratorTabClick) }, @@ -89,6 +94,7 @@ fun VaultUnlockedNavBarScreen( @Composable @Suppress("LongMethod") private fun VaultUnlockedNavBarScaffold( + onNavigateToAccountSecurity: () -> Unit, navController: NavHostController, vaultTabClickedAction: () -> Unit, sendTabClickedAction: () -> Unit, @@ -162,7 +168,9 @@ private fun VaultUnlockedNavBarScaffold( vaultDestination() sendDestination() generatorDestination() - settingsDestination() + settingsDestinations( + onNavigateToAccountSecurity = onNavigateToAccountSecurity, + ) } } } @@ -301,32 +309,3 @@ private fun NavController.navigateToSend(navOptions: NavOptions? = null) { navigate(SEND_ROUTE, navOptions) } // #endregion Send - -// #region Settings -/** - * TODO: move to settings package (BIT-147) - */ -private const val SETTINGS_ROUTE = "settings" - -/** - * Add settings destination to the nav graph. - * - * TODO: move to settings package (BIT-147) - */ -private fun NavGraphBuilder.settingsDestination() { - composable(SETTINGS_ROUTE) { - PlaceholderComposable(text = "Settings") - } -} - -/** - * Navigate to the generator screen. Note this will only work if generator screen was added - * via [settingsDestination]. - * - * TODO: move to settings package (BIT-147) - * - */ -private fun NavController.navigateToSettings(navOptions: NavOptions? = null) { - navigate(SETTINGS_ROUTE, navOptions) -} -// #endregion Settings diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 000000000..8bef9f148 --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,9 @@ + + + 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 b0e6439c8..ee44356f6 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 @@ -202,6 +202,31 @@ class AuthRepositoryTest { } } + @Test + fun `logout should change AuthState to be Unauthenticated`() = runTest { + // First login: + coEvery { + accountsService.preLogin(email = EMAIL) + } returns Result.success(PRE_LOGIN_SUCCESS) + coEvery { + identityService.getToken( + email = EMAIL, + passwordHash = PASSWORD_HASH, + captchaToken = null, + ) + } + .returns(Result.success(GetTokenResponseJson.Success(accessToken = ACCESS_TOKEN))) + every { authInterceptor.authToken = ACCESS_TOKEN } returns Unit + repository.login(email = EMAIL, password = PASSWORD, captchaToken = null) + + // Then call logout: + repository.authStateFlow.test { + assertEquals(AuthState.Authenticated(ACCESS_TOKEN), awaitItem()) + repository.logout() + assertEquals(AuthState.Unauthenticated, awaitItem()) + } + } + companion object { private const val EMAIL = "test@test.com" private const val PASSWORD = "password" 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 68257b55f..a790dfb92 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 @@ -19,10 +19,10 @@ class RootNavScreenTest : BaseComposeTest() { // When changing root navigation state, pop everything else off the back stack: popUpTo(fakeNavHostController.graphId) { inclusive = false - saveState = true + saveState = false } launchSingleTop = true - restoreState = true + restoreState = false } @Test diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityScreenTest.kt new file mode 100644 index 000000000..d83f1dfb2 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityScreenTest.kt @@ -0,0 +1,62 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Test + +class AccountSecurityScreenTest : BaseComposeTest() { + + @Test + fun `on Log out click should send LogoutClick`() { + val viewModel: AccountSecurityViewModel = mockk { + every { eventFlow } returns emptyFlow() + every { trySendAction(AccountSecurityAction.LogoutClick) } returns Unit + } + composeTestRule.setContent { + AccountSecurityScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithText("Log out").performClick() + verify { viewModel.trySendAction(AccountSecurityAction.LogoutClick) } + } + + @Test + fun `on back click should send BackClick`() { + val viewModel: AccountSecurityViewModel = mockk { + every { eventFlow } returns emptyFlow() + every { trySendAction(AccountSecurityAction.BackClick) } returns Unit + } + composeTestRule.setContent { + AccountSecurityScreen( + viewModel = viewModel, + onNavigateBack = { }, + ) + } + composeTestRule.onNodeWithContentDescription("Back").performClick() + verify { viewModel.trySendAction(AccountSecurityAction.BackClick) } + } + + @Test + fun `on NavigateAccountSecurity should call onNavigateToAccountSecurity`() { + var haveCalledNavigateBack = false + val viewModel = mockk { + every { eventFlow } returns flowOf(AccountSecurityEvent.NavigateBack) + } + composeTestRule.setContent { + AccountSecurityScreen( + viewModel = viewModel, + onNavigateBack = { haveCalledNavigateBack = true }, + ) + } + assert(haveCalledNavigateBack) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityViewModelTest.kt new file mode 100644 index 000000000..343eddbcf --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/AccountSecurityViewModelTest.kt @@ -0,0 +1,37 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AccountSecurityViewModelTest : BaseViewModelTest() { + + @Test + fun `on BackClick should emit NavigateBack`() = runTest { + val viewModel = AccountSecurityViewModel( + authRepository = mockk(), + ) + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.BackClick) + assertEquals(AccountSecurityEvent.NavigateBack, awaitItem()) + } + } + + @Test + fun `on LogoutClick should call logout`() = runTest { + val authRepository: AuthRepository = mockk { + every { logout() } returns Unit + } + val viewModel = AccountSecurityViewModel( + authRepository = authRepository, + ) + viewModel.trySendAction(AccountSecurityAction.LogoutClick) + verify { authRepository.logout() } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt new file mode 100644 index 000000000..8cc2549db --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsScreenTest.kt @@ -0,0 +1,47 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Test + +class SettingsScreenTest : BaseComposeTest() { + + @Test + fun `on account row click should emit AccountSecurityClick`() { + val viewModel = mockk { + every { eventFlow } returns emptyFlow() + every { trySendAction(SettingsAction.AccountSecurityClick) } returns Unit + } + composeTestRule.setContent { + SettingsScreen( + viewModel = viewModel, + onNavigateToAccountSecurity = { }, + ) + } + composeTestRule.onNodeWithText("Account").performClick() + verify { viewModel.trySendAction(SettingsAction.AccountSecurityClick) } + } + + @Test + fun `on NavigateAccountSecurity should call onNavigateToAccountSecurity`() { + var haveCalledNavigateToAccountSecurity = false + val viewModel = mockk { + every { eventFlow } returns flowOf(SettingsEvent.NavigateAccountSecurity) + } + composeTestRule.setContent { + SettingsScreen( + viewModel = viewModel, + onNavigateToAccountSecurity = { + haveCalledNavigateToAccountSecurity = true + }, + ) + } + assert(haveCalledNavigateToAccountSecurity) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt new file mode 100644 index 000000000..b695225ea --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModelTest.kt @@ -0,0 +1,19 @@ +package com.x8bit.bitwarden.ui.platform.feature.settings + +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 SettingsViewModelTest : BaseViewModelTest() { + + @Test + fun `on AccountSecurityClick should emit NavigateAccountSecurity`() = runTest { + val viewModel = SettingsViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(SettingsAction.AccountSecurityClick) + assertEquals(SettingsEvent.NavigateAccountSecurity, awaitItem()) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt index cf75045d8..ffd5d8c1b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/vaultunlockednavbar/VaultUnlockedNavBarScreenTest.kt @@ -32,6 +32,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarScreen( viewModel = viewModel, navController = fakeNavHostController, + onNavigateToAccountSecurity = {}, ) } onNodeWithText("My vault").performClick() @@ -52,6 +53,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarScreen( viewModel = viewModel, navController = fakeNavHostController, + onNavigateToAccountSecurity = {}, ) } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } @@ -73,6 +75,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarScreen( viewModel = viewModel, navController = fakeNavHostController, + onNavigateToAccountSecurity = {}, ) } onNodeWithText("Send").performClick() @@ -93,6 +96,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarScreen( viewModel = viewModel, navController = fakeNavHostController, + onNavigateToAccountSecurity = {}, ) } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } @@ -114,6 +118,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarScreen( viewModel = viewModel, navController = fakeNavHostController, + onNavigateToAccountSecurity = {}, ) } onNodeWithText("Generator").performClick() @@ -134,6 +139,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarScreen( viewModel = viewModel, navController = fakeNavHostController, + onNavigateToAccountSecurity = {}, ) } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") } @@ -155,6 +161,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarScreen( viewModel = viewModel, navController = fakeNavHostController, + onNavigateToAccountSecurity = {}, ) } onNodeWithText("Settings").performClick() @@ -175,6 +182,7 @@ class VaultUnlockedNavBarScreenTest : BaseComposeTest() { VaultUnlockedNavBarScreen( viewModel = viewModel, navController = fakeNavHostController, + onNavigateToAccountSecurity = {}, ) } runOnIdle { fakeNavHostController.assertCurrentRoute("vault") }