diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt b/app/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt index 14eea861d1..16703b7ef3 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/MainActivity.kt @@ -38,7 +38,8 @@ class MainActivity : AppCompatActivity() { theme = state.theme ) { RootNavScreen( - onSplashScreenRemoved = { shouldShowSplashScreen = false } + onSplashScreenRemoved = { shouldShowSplashScreen = false }, + onExitApplication = { finishAffinity() } ) } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt index a29647b78a..7ac0b84df8 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/authenticator/AuthenticatorNavigation.kt @@ -29,12 +29,14 @@ fun NavController.navigateToAuthenticatorGraph(navOptions: NavOptions? = null) { */ fun NavGraphBuilder.authenticatorGraph( navController: NavController, + onNavigateBack: () -> Unit, ) { navigation( startDestination = AUTHENTICATOR_NAV_BAR_ROUTE, route = AUTHENTICATOR_GRAPH_ROUTE ) { authenticatorNavBarDestination( + onNavigateBack = onNavigateBack, onNavigateToSearch = { navController.navigateToSearch() }, onNavigateToQrCodeScanner = { navController.navigateToQrCodeScanScreen() }, onNavigateToManualKeyEntry = { navController.navigateToManualCodeEntryScreen() }, @@ -44,6 +46,7 @@ fun NavGraphBuilder.authenticatorGraph( ) itemListingGraph( navController = navController, + navigateBack = onNavigateBack, navigateToSearch = { navController.navigateToSearch() }, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt index a5009137e4..1449b79e92 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/itemlisting/ItemListingGraphNavigation.kt @@ -19,6 +19,7 @@ const val ITEM_LISTING_GRAPH_ROUTE = "item_listing_graph" */ fun NavGraphBuilder.itemListingGraph( navController: NavController, + navigateBack: () -> Unit, navigateToSearch: () -> Unit, navigateToQrCodeScanner: () -> Unit, navigateToManualKeyEntry: () -> Unit, @@ -31,7 +32,7 @@ fun NavGraphBuilder.itemListingGraph( startDestination = ITEM_LIST_ROUTE, ) { itemListingDestination( - onNavigateBack = { navController.popBackStack() }, + onNavigateBack = navigateBack, onNavigateToSearch = navigateToSearch, onNavigateToQrCodeScanner = navigateToQrCodeScanner, onNavigateToManualKeyEntry = navigateToManualKeyEntry, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt index 36cbb06f0a..66da473617 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarNavigation.kt @@ -9,6 +9,7 @@ const val AUTHENTICATOR_NAV_BAR_ROUTE: String = "AuthenticatorNavBarRoute" * Add the authenticator nav bar to the nav graph. */ fun NavGraphBuilder.authenticatorNavBarDestination( + onNavigateBack: () -> Unit, onNavigateToSearch: () -> Unit, onNavigateToQrCodeScanner: () -> Unit, onNavigateToManualKeyEntry: () -> Unit, @@ -20,6 +21,7 @@ fun NavGraphBuilder.authenticatorNavBarDestination( route = AUTHENTICATOR_NAV_BAR_ROUTE, ) { AuthenticatorNavBarScreen( + onNavigateBack = onNavigateBack, onNavigateToQrCodeScanner = onNavigateToQrCodeScanner, onNavigateToManualKeyEntry = onNavigateToManualKeyEntry, onNavigateToEditItem = onNavigateToEditItem, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt index 35d0cf8fd7..6be2d0c51e 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/authenticator/feature/navbar/AuthenticatorNavBarScreen.kt @@ -67,6 +67,7 @@ import kotlinx.parcelize.Parcelize fun AuthenticatorNavBarScreen( viewModel: AuthenticatorNavBarViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), + onNavigateBack: () -> Unit, onNavigateToSearch: () -> Unit, onNavigateToQrCodeScanner: () -> Unit, onNavigateToManualKeyEntry: () -> Unit, @@ -106,6 +107,7 @@ fun AuthenticatorNavBarScreen( settingsTabClickedAction = { viewModel.trySendAction(AuthenticatorNavBarAction.SettingsTabClick) }, + navigateBack = onNavigateBack, navigateToSearch = onNavigateToSearch, navigateToQrCodeScanner = onNavigateToQrCodeScanner, navigateToManualKeyEntry = onNavigateToManualKeyEntry, @@ -121,6 +123,7 @@ private fun AuthenticatorNavBarScaffold( navController: NavHostController, verificationTabClickedAction: () -> Unit, settingsTabClickedAction: () -> Unit, + navigateBack: () -> Unit, navigateToSearch: () -> Unit, navigateToQrCodeScanner: () -> Unit, navigateToManualKeyEntry: () -> Unit, @@ -168,6 +171,7 @@ private fun AuthenticatorNavBarScaffold( ) { itemListingGraph( navController = navController, + navigateBack = navigateBack, navigateToSearch = navigateToSearch, navigateToQrCodeScanner = navigateToQrCodeScanner, navigateToManualKeyEntry = navigateToManualKeyEntry, diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt index 37a6b76d9d..c6b5ffcf40 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavScreen.kt @@ -37,11 +37,12 @@ fun RootNavScreen( viewModel: RootNavViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), onSplashScreenRemoved: () -> Unit = {}, + onExitApplication: () -> Unit, ) { val state by viewModel.stateFlow.collectAsState() val previousStateReference = remember { AtomicReference(state) } - val isNotSplashScreen = state !is RootNavState.Splash + val isNotSplashScreen = state.navState !is RootNavState.NavState.Splash LaunchedEffect(isNotSplashScreen) { if (isNotSplashScreen) { onSplashScreenRemoved() @@ -64,17 +65,27 @@ fun RootNavScreen( popEnterTransition = { toEnterTransition()(this) }, popExitTransition = { toExitTransition()(this) }, ) { - splashDestination() + splashDestination( + onSplashScreenDismissed = { + viewModel.trySendAction(RootNavAction.Internal.SplashScreenDismissed) + }, + onExitApplication = onExitApplication, + ) tutorialDestination( - onTutorialFinished = { navController.navigateToAuthenticatorGraph() } + onTutorialFinished = { + viewModel.trySendAction(RootNavAction.Internal.TutorialFinished) + }, + ) + authenticatorGraph( + navController = navController, + onNavigateBack = onExitApplication, ) - authenticatorGraph(navController) } - val targetRoute = when (state) { - RootNavState.ItemListing -> AUTHENTICATOR_GRAPH_ROUTE - RootNavState.Splash -> SPLASH_ROUTE - RootNavState.Tutorial -> TUTORIAL_ROUTE + val targetRoute = when (state.navState) { + RootNavState.NavState.ItemListing -> AUTHENTICATOR_GRAPH_ROUTE + RootNavState.NavState.Splash -> SPLASH_ROUTE + RootNavState.NavState.Tutorial -> TUTORIAL_ROUTE } val currentRoute = navController.currentDestination?.rootLevelRoute() @@ -100,10 +111,18 @@ fun RootNavScreen( } LaunchedEffect(state) { - when (state) { - RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) - RootNavState.Tutorial -> navController.navigateToTutorial(rootNavOptions) - RootNavState.ItemListing -> navController.navigateToAuthenticatorGraph(rootNavOptions) + when (state.navState) { + RootNavState.NavState.Splash -> { + navController.navigateToSplash(rootNavOptions) + } + + RootNavState.NavState.Tutorial -> { + navController.navigateToTutorial(rootNavOptions) + } + + RootNavState.NavState.ItemListing -> { + navController.navigateToAuthenticatorGraph(rootNavOptions) + } } } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt index be4513dcb4..556190d4c6 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -1,16 +1,11 @@ package com.bitwarden.authenticator.ui.platform.feature.rootnav import android.os.Parcelable -import androidx.lifecycle.viewModelScope import com.bitwarden.authenticator.data.auth.repository.AuthRepository import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -19,18 +14,12 @@ class RootNavViewModel @Inject constructor( private val authRepository: AuthRepository, private val settingsRepository: SettingsRepository, ) : BaseViewModel( - initialState = RootNavState.Splash + initialState = RootNavState( + hasSeenWelcomeGuide = settingsRepository.hasSeenWelcomeTutorial, + navState = RootNavState.NavState.Splash, + ) ) { - init { - viewModelScope.launch { - settingsRepository.hasSeenWelcomeTutorialFlow - .map { RootNavAction.Internal.HasSeenWelcomeTutorialChange(it) } - .onEach(::sendAction) - .launchIn(viewModelScope) - } - } - override fun handleAction(action: RootNavAction) { when (action) { RootNavAction.BackStackUpdate -> { @@ -40,6 +29,14 @@ class RootNavViewModel @Inject constructor( is RootNavAction.Internal.HasSeenWelcomeTutorialChange -> { handleHasSeenWelcomeTutorialChange(action.hasSeenWelcomeGuide) } + + RootNavAction.Internal.TutorialFinished -> { + handleTutorialFinished() + } + + RootNavAction.Internal.SplashScreenDismissed -> { + handleSplashScreenDismissed() + } } } @@ -49,9 +46,21 @@ class RootNavViewModel @Inject constructor( private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) { if (hasSeenWelcomeGuide) { - mutableStateFlow.update { RootNavState.ItemListing } + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.ItemListing) } } else { - mutableStateFlow.update { RootNavState.Tutorial } + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Tutorial) } + } + } + + private fun handleTutorialFinished() { + settingsRepository.hasSeenWelcomeTutorial = true + } + + private fun handleSplashScreenDismissed() { + if (settingsRepository.hasSeenWelcomeTutorial) { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.ItemListing) } + } else { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Tutorial) } } } } @@ -59,25 +68,32 @@ class RootNavViewModel @Inject constructor( /** * Models root level destinations for the app. */ -sealed class RootNavState : Parcelable { +@Parcelize +data class RootNavState( + val hasSeenWelcomeGuide: Boolean, + val navState: NavState, +) : Parcelable { - /** - * App should display the Splash nav graph. - */ @Parcelize - data object Splash : RootNavState() + sealed class NavState : Parcelable { + /** + * App should display the Splash nav graph. + */ + @Parcelize + data object Splash : NavState() - /** - * App should display the Tutorial nav graph. - */ - @Parcelize - data object Tutorial : RootNavState() + /** + * App should display the Tutorial nav graph. + */ + @Parcelize + data object Tutorial : NavState() - /** - * App should display the Account List nav graph. - */ - @Parcelize - data object ItemListing : RootNavState() + /** + * App should display the Account List nav graph. + */ + @Parcelize + data object ItemListing : NavState() + } } /** @@ -94,6 +110,10 @@ sealed class RootNavAction { */ sealed class Internal : RootNavAction() { + data object SplashScreenDismissed : Internal() + + data object TutorialFinished : Internal() + /** * Indicates an update in the welcome guide being seen has been received. */ diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt index c837b3b126..b157785359 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics @@ -41,8 +42,11 @@ import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenSelect import com.bitwarden.authenticator.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.authenticator.ui.platform.components.row.BitwardenTextRow import com.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.components.toggle.BitwardenWideSwitch import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalBiometricsManager import com.bitwarden.authenticator.ui.platform.util.displayLabel /** @@ -52,6 +56,7 @@ import com.bitwarden.authenticator.ui.platform.util.displayLabel @Composable fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, onNavigateToTutorial: () -> Unit, onNavigateToExport: () -> Unit, ) { @@ -81,6 +86,18 @@ fun SettingsScreen( .fillMaxSize() .verticalScroll(state = rememberScrollState()) ) { + SecuritySettings( + state = state, + biometricsManager = biometricsManager, + onBiometricToggle = remember(viewModel) { + { + viewModel.trySendAction( + SettingsAction.SecurityClick.UnlockWithBiometricToggle(it) + ) + } + } + ) + Spacer(modifier = Modifier.height(16.dp)) VaultSettings( onExportClick = remember(viewModel) { { @@ -114,12 +131,39 @@ fun SettingsScreen( } } +//region Security settings + +@Composable +fun SecuritySettings( + state: SettingsState, + biometricsManager: BiometricsManager = LocalBiometricsManager.current, + onBiometricToggle: (Boolean) -> Unit, +) { + if (!biometricsManager.isBiometricsSupported) return + + BitwardenListHeaderText( + modifier = Modifier.padding(horizontal = 16.dp), + label = stringResource(id = R.string.security) + ) + Spacer(modifier = Modifier.height(8.dp)) + UnlockWithBiometricsRow( + modifier = Modifier + .testTag("UnlockWithBiometricsSwitch") + .fillMaxWidth() + .padding(horizontal = 16.dp), + isChecked = state.isUnlockWithBiometricsEnabled, + onBiometricToggle = { onBiometricToggle(it) }, + biometricsManager = biometricsManager, + ) +} + +//endregion + //region Vault settings @Composable fun VaultSettings( modifier: Modifier = Modifier, - onExportClick: () -> Unit, ) { BitwardenListHeaderText( @@ -145,6 +189,41 @@ fun VaultSettings( ) } +@Composable +private fun UnlockWithBiometricsRow( + isChecked: Boolean, + onBiometricToggle: (Boolean) -> Unit, + biometricsManager: BiometricsManager, + modifier: Modifier = Modifier, +) { + if (!biometricsManager.isBiometricsSupported) return + var showBiometricsPrompt by rememberSaveable { mutableStateOf(false) } + BitwardenWideSwitch( + modifier = modifier, + label = stringResource( + id = R.string.unlock_with, + stringResource(id = R.string.biometrics), + ), + isChecked = isChecked || showBiometricsPrompt, + onCheckedChange = { toggled -> + if (toggled) { + showBiometricsPrompt = true + biometricsManager.promptBiometrics( + onSuccess = { + onBiometricToggle(true) + showBiometricsPrompt = false + }, + onCancel = { showBiometricsPrompt = false }, + onLockOut = { showBiometricsPrompt = false }, + onError = { showBiometricsPrompt = false }, + ) + } else { + onBiometricToggle(false) + } + }, + ) +} + //endregion Vault settings //region Appearance settings diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt index c96c33ee62..0b00826fb0 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/settings/SettingsViewModel.kt @@ -4,12 +4,18 @@ import android.os.Parcelable import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.data.platform.repository.model.BiometricsKeyResult import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppLanguage import com.bitwarden.authenticator.ui.platform.feature.settings.appearance.model.AppTheme import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject @@ -29,18 +35,88 @@ class SettingsViewModel @Inject constructor( language = settingsRepository.appLanguage, theme = settingsRepository.appTheme, ), + isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + dialog = null, ), ) { override fun handleAction(action: SettingsAction) { when (action) { - is SettingsAction.VaultClick -> handleVaultClick(action) - is SettingsAction.AppearanceChange -> handleAppearanceChange(action) - is SettingsAction.HelpClick -> handleHelpClick(action) + is SettingsAction.SecurityClick -> { + handleSecurityClick(action) + } + + is SettingsAction.VaultClick -> { + handleVaultClick(action) + } + + is SettingsAction.AppearanceChange -> { + handleAppearanceChange(action) + } + + is SettingsAction.HelpClick -> { + handleHelpClick(action) + } + + is SettingsAction.Internal.BiometricsKeyResultReceive -> { + handleBiometricsKeyResultReceive(action) + } + } + } + + private fun handleSecurityClick(action: SettingsAction.SecurityClick) { + when (action) { + is SettingsAction.SecurityClick.UnlockWithBiometricToggle -> { + handleBiometricsSetupClick(action) + } + } + } + + private fun handleBiometricsSetupClick( + action: SettingsAction.SecurityClick.UnlockWithBiometricToggle, + ) { + if (action.enabled) { + mutableStateFlow.update { + it.copy( + dialog = SettingsState.Dialog.Loading(R.string.saving.asText()), + isUnlockWithBiometricsEnabled = true, + ) + } + viewModelScope.launch { + val result = settingsRepository.setupBiometricsKey() + sendAction(SettingsAction.Internal.BiometricsKeyResultReceive(result)) + } + } else { + settingsRepository.clearBiometricsKey() + mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = false) } + } + } + + private fun handleBiometricsKeyResultReceive( + action: SettingsAction.Internal.BiometricsKeyResultReceive, + ) { + when (action.result) { + BiometricsKeyResult.Error -> { + mutableStateFlow.update { + it.copy( + dialog = null, + isUnlockWithBiometricsEnabled = false, + ) + } + } + + BiometricsKeyResult.Success -> { + mutableStateFlow.update { + it.copy( + dialog = null, + isUnlockWithBiometricsEnabled = true, + ) + } + } } } private fun handleVaultClick(action: SettingsAction.VaultClick) { - when(action) { + when (action) { SettingsAction.VaultClick.ExportClick -> handleExportClick() } } @@ -100,7 +176,18 @@ class SettingsViewModel @Inject constructor( @Parcelize data class SettingsState( val appearance: Appearance, + val isUnlockWithBiometricsEnabled: Boolean, + val dialog: Dialog?, ) : Parcelable { + + @Parcelize + sealed class Dialog : Parcelable { + + data class Loading( + val message: Text, + ) : Dialog() + } + /** * Models state of the Appearance settings. */ @@ -123,7 +210,19 @@ sealed class SettingsEvent { /** * Models actions for the settings screen. */ -sealed class SettingsAction { +sealed class SettingsAction( + val dialog: Dialog? = null, +) { + + sealed class Dialog { + data class Loading( + val message: Text, + ) : Dialog() + } + + sealed class SecurityClick : SettingsAction() { + data class UnlockWithBiometricToggle(val enabled: Boolean) : SecurityClick() + } /** * Models actions for the Vault section of settings. @@ -165,4 +264,11 @@ sealed class SettingsAction { val appTheme: AppTheme, ) : AppearanceChange() } + + sealed class Internal { + class BiometricsKeyResultReceive(val result: BiometricsKeyResult) : SettingsAction() { + + } + + } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt index bbca8c418d..d981c0b266 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt @@ -10,8 +10,16 @@ const val SPLASH_ROUTE: String = "splash" /** * Add splash destinations to the nav graph. */ -fun NavGraphBuilder.splashDestination() { - composable(SPLASH_ROUTE) { SplashScreen() } +fun NavGraphBuilder.splashDestination( + onSplashScreenDismissed: () -> Unit, + onExitApplication: () -> Unit, +) { + composable(SPLASH_ROUTE) { + SplashScreen( + onNavigateToAuthenticator = onSplashScreenDismissed, + onExitApplication = onExitApplication, + ) + } } /** diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt index ee35505424..3d45dd9b6c 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashScreen.kt @@ -1,14 +1,119 @@ package com.bitwarden.authenticator.ui.platform.feature.splash +import android.widget.Toast import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.ui.platform.base.util.EventsEffect +import com.bitwarden.authenticator.ui.platform.base.util.asText +import com.bitwarden.authenticator.ui.platform.components.dialog.BasicDialogState +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalBiometricsManager /** * Splash screen with empty composable content so that the Activity window background is shown. */ @Composable -fun SplashScreen() { +fun SplashScreen( + viewModel: SplashViewModel = hiltViewModel(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, + onNavigateToAuthenticator: () -> Unit, + onExitApplication: () -> Unit, +) { + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val resources = context.resources + + EventsEffect(viewModel) { event -> + when (event) { + is SplashEvent.NavigateToAuthenticator -> onNavigateToAuthenticator() + + is SplashEvent.ShowToast -> { + Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() + } + + is SplashEvent.ExitApplication -> onExitApplication() + } + } + + when (val dialog = state.dialog) { + is SplashState.Dialog.Error -> { + BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = dialog.message + ), + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction(SplashAction.DismissDialog) + } + }, + ) + } + + SplashState.Dialog.Loading -> { + BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown( + text = R.string.loading.asText() + ), + ) + } + + null -> Unit + } + Box(modifier = Modifier.fillMaxSize()) + + when (val viewState = state.viewState) { + is SplashState.ViewState.Locked -> { + if (viewState.showBiometricsPrompt) { + biometricsManager.promptBiometrics( + onSuccess = remember(viewModel) { + { + viewModel.trySendAction( + SplashAction.Internal.UnlockResultReceived(UnlockResult.Success) + ) + } + }, + onCancel = remember(viewModel) { + { + viewModel.trySendAction( + SplashAction.Internal.UnlockResultReceived(UnlockResult.Cancel) + ) + } + }, + onError = remember(viewModel) { + { + viewModel.trySendAction( + SplashAction.Internal.UnlockResultReceived(UnlockResult.Error), + ) + } + }, + onLockOut = remember(viewModel) { + { + viewModel.trySendAction( + SplashAction.Internal.UnlockResultReceived(UnlockResult.LockOut), + ) + } + }, + ) + } else { + viewModel.trySendAction( + SplashAction.Internal.UnlockResultReceived(UnlockResult.Success) + ) + } + } + + SplashState.ViewState.Unlocked -> Unit + } } diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashViewModel.kt new file mode 100644 index 0000000000..f27a25208d --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashViewModel.kt @@ -0,0 +1,148 @@ +package com.bitwarden.authenticator.ui.platform.feature.splash + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.platform.manager.BiometricsEncryptionManager +import com.bitwarden.authenticator.data.platform.repository.SettingsRepository +import com.bitwarden.authenticator.ui.platform.base.BaseViewModel +import com.bitwarden.authenticator.ui.platform.base.util.Text +import com.bitwarden.authenticator.ui.platform.base.util.asText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +@HiltViewModel +class SplashViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + settingsRepository: SettingsRepository, + private val biometricsEncryptionManager: BiometricsEncryptionManager, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: run { + val showBiometricsPrompt = settingsRepository.isUnlockWithBiometricsEnabled + && biometricsEncryptionManager.isBiometricIntegrityValid() + SplashState( + isBiometricEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(), + viewState = SplashState.ViewState.Locked(showBiometricsPrompt = showBiometricsPrompt), + dialog = null, + ) + } +) { + + override fun handleAction(action: SplashAction) { + when (action) { + SplashAction.DismissDialog -> handleDismissDialog() + is SplashAction.Internal -> handleInternalAction(action) + } + } + + private fun handleDismissDialog() { + mutableStateFlow.update { + it.copy(dialog = null) + } + } + + private fun handleInternalAction(action: SplashAction.Internal) { + when (action) { + is SplashAction.Internal.UnlockResultReceived -> handleUnlockResultReceive(action) + } + } + + private fun handleUnlockResultReceive(action: SplashAction.Internal.UnlockResultReceived) { + when (action.unlockResult) { + UnlockResult.Error -> { + mutableStateFlow.update { + it.copy( + viewState = SplashState.ViewState.Locked(showBiometricsPrompt = false), + dialog = SplashState.Dialog.Error(R.string.generic_error_message.asText()) + ) + } + sendEvent(SplashEvent.ExitApplication) + } + + UnlockResult.LockOut, + UnlockResult.Cancel, + -> { + mutableStateFlow.update { + it.copy( + viewState = SplashState.ViewState.Locked( + showBiometricsPrompt = false + ) + ) + } + sendEvent(SplashEvent.ExitApplication) + } + + UnlockResult.Success -> { + mutableStateFlow.update { + it.copy( + viewState = SplashState.ViewState.Unlocked, + dialog = null, + ) + } + if (state.isBiometricEnabled && !state.isBiometricsValid) { + biometricsEncryptionManager.setupBiometrics() + } + sendEvent(SplashEvent.NavigateToAuthenticator) + } + } + } +} + +data class SplashState( + val isBiometricEnabled: Boolean, + val isBiometricsValid: Boolean, + val viewState: ViewState, + val dialog: Dialog?, +) { + sealed class ViewState { + data object Unlocked : ViewState() + + data class Locked( + val showBiometricsPrompt: Boolean, + ) : ViewState() + } + + sealed class Dialog : Parcelable { + + @Parcelize + data class Error( + val message: Text, + ) : Dialog() + + @Parcelize + data object Loading : Dialog() + } +} + +sealed class SplashEvent { + data object NavigateToAuthenticator : SplashEvent() + + data class ShowToast(val message: Text) : SplashEvent() + + data object ExitApplication : SplashEvent() +} + +sealed class SplashAction { + data object DismissDialog : SplashAction() + + sealed class Internal : SplashAction() { + data class UnlockResultReceived( + val unlockResult: UnlockResult, + ) : Internal() + } +} + +sealed class UnlockResult { + data object Success : UnlockResult() + + data object Cancel : UnlockResult() + + data object LockOut : UnlockResult() + + data object Error : UnlockResult() +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt index 19d06070bb..84987f6733 100644 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/tutorial/TutorialViewModel.kt @@ -1,9 +1,7 @@ package com.bitwarden.authenticator.ui.platform.feature.tutorial import android.os.Parcelable -import androidx.lifecycle.SavedStateHandle import com.bitwarden.authenticator.R -import com.bitwarden.authenticator.data.platform.repository.SettingsRepository import com.bitwarden.authenticator.ui.platform.base.BaseViewModel import com.bitwarden.authenticator.ui.platform.base.util.Text import com.bitwarden.authenticator.ui.platform.base.util.asText @@ -12,18 +10,13 @@ import kotlinx.coroutines.flow.update import kotlinx.parcelize.Parcelize import javax.inject.Inject -private const val KEY_STATE = "state" - /** * View model for the [TutorialScreen]. */ @HiltViewModel -class TutorialViewModel @Inject constructor( - private val settingsRepository: SettingsRepository, - savedStateHandle: SavedStateHandle, -) : +class TutorialViewModel @Inject constructor() : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] ?: TutorialState.IntroSlide + initialState = TutorialState.IntroSlide ) { override fun handleAction(action: TutorialAction) { @@ -56,7 +49,6 @@ class TutorialViewModel @Inject constructor( TutorialState.IntroSlide -> TutorialEvent.NavigateToQrScannerSlide TutorialState.QrScannerSlide -> TutorialEvent.NavigateToUniqueCodesSlide TutorialState.UniqueCodesSlide -> { - settingsRepository.hasSeenWelcomeTutorial = true TutorialEvent.NavigateToAuthenticator } } @@ -64,7 +56,6 @@ class TutorialViewModel @Inject constructor( } private fun handleSkipClick() { - settingsRepository.hasSeenWelcomeTutorial = true sendEvent(TutorialEvent.NavigateToAuthenticator) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ce1d31c20..046409a98d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -90,4 +90,7 @@ File format There was a problem exporting your vault. If the problem persists, you\\\'ll need to export from the web vault. Data exported successfully + Unlock with %1$s + Biometrics + Security