diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UnlockResult.kt b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UnlockResult.kt new file mode 100644 index 000000000..caea1494c --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/data/authenticator/repository/model/UnlockResult.kt @@ -0,0 +1,27 @@ +package com.bitwarden.authenticator.data.authenticator.repository.model + +/** + * Models result of unlocking the vault. + */ +sealed class UnlockResult { + + /** + * Vault successfully unlocked. + */ + data object Success : UnlockResult() + + /** + * Incorrect password provided. + */ + data object AuthenticationError : UnlockResult() + + /** + * Unable to access user state information. + */ + data object InvalidStateError : UnlockResult() + + /** + * Generic error thrown by Bitwarden SDK. + */ + data object GenericError : UnlockResult() +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt new file mode 100644 index 000000000..ca40fa54a --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockNavigation.kt @@ -0,0 +1,22 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable + +const val UNLOCK_ROUTE: String = "unlock" + +fun NavController.navigateToUnlock( + navOptions: NavOptions? = null, +) { + navigate(route = UNLOCK_ROUTE, navOptions = navOptions) +} + +fun NavGraphBuilder.unlockDestination( + onUnlocked: () -> Unit, +) { + composable(route = UNLOCK_ROUTE) { + UnlockScreen(onUnlocked = onUnlocked) + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt new file mode 100644 index 000000000..12d7306ae --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockScreen.kt @@ -0,0 +1,146 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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 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.button.BitwardenOutlinedButton +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.components.scaffold.BitwardenScaffold +import com.bitwarden.authenticator.ui.platform.manager.biometrics.BiometricsManager +import com.bitwarden.authenticator.ui.platform.theme.LocalBiometricsManager + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UnlockScreen( + viewModel: UnlockViewModel = hiltViewModel(), + biometricsManager: BiometricsManager = LocalBiometricsManager.current, + onUnlocked: () -> Unit, +) { + + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val context = LocalContext.current + val resources = context.resources + var showBiometricsPrompt by remember { mutableStateOf(true) } + + EventsEffect(viewModel = viewModel) { event -> + when (event) { + is UnlockEvent.ShowToast -> { + Toast.makeText(context, event.message(resources), Toast.LENGTH_SHORT).show() + } + + UnlockEvent.BiometricUnlock -> onUnlocked() + } + } + + when (val dialog = state.dialog) { + is UnlockState.Dialog.Error -> BitwardenBasicDialog( + visibilityState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = dialog.message + ), + onDismissRequest = remember(viewModel) { + { + viewModel.trySendAction(UnlockAction.DismissDialog) + } + }, + ) + + UnlockState.Dialog.Loading -> BitwardenLoadingDialog( + visibilityState = LoadingDialogState.Shown(R.string.loading.asText()) + ) + + null -> Unit + } + + val onBiometricsUnlock: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.BiometricsUnlock) } + } + val onBiometricsLockOut: () -> Unit = remember(viewModel) { + { viewModel.trySendAction(UnlockAction.BiometricsLockout) } + } + + if (showBiometricsPrompt) { + biometricsManager.promptBiometrics( + onSuccess = { + showBiometricsPrompt = false + onBiometricsUnlock() + }, + onCancel = { + showBiometricsPrompt = false + }, + onError = { + showBiometricsPrompt = false + }, + onLockOut = { + showBiometricsPrompt = false + onBiometricsLockOut() + }, + ) + } + + BitwardenScaffold( + modifier = Modifier + .fillMaxSize() + ) { innerPadding -> + Box { + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_logo_horizontal), + contentDescription = stringResource(R.string.bitwarden_authenticator) + ) + Spacer(modifier = Modifier.height(12.dp)) + BitwardenOutlinedButton( + label = stringResource(id = R.string.use_biometrics_to_unlock), + onClick = { + biometricsManager.promptBiometrics( + onSuccess = onBiometricsUnlock, + onCancel = { + // no-op + }, + onError = { + // no-op + }, + onLockOut = onBiometricsLockOut, + ) + }, + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + ) + } + } + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt new file mode 100644 index 000000000..94a734f5b --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/auth/unlock/UnlockViewModel.kt @@ -0,0 +1,120 @@ +package com.bitwarden.authenticator.ui.auth.unlock + +import android.os.Parcelable +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.bitwarden.authenticator.R +import com.bitwarden.authenticator.data.authenticator.repository.model.UnlockResult +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.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.parcelize.Parcelize +import javax.inject.Inject + +private const val KEY_STATE = "state" + +@HiltViewModel +class UnlockViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val settingsRepository: SettingsRepository, + private val biometricsEncryptionManager: BiometricsEncryptionManager, +) : BaseViewModel( + initialState = savedStateHandle[KEY_STATE] ?: run { + UnlockState( + isBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled, + isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(), + dialog = null, + ) + } +) { + + init { + stateFlow + .onEach { savedStateHandle[KEY_STATE] = it } + .launchIn(viewModelScope) + } + + override fun handleAction(action: UnlockAction) { + when (action) { + UnlockAction.BiometricsUnlock -> { + handleBiometricsUnlock() + } + + UnlockAction.DismissDialog -> { + handleDismissDialog() + } + + UnlockAction.BiometricsLockout -> { + handleBiometricsLockout() + } + } + } + + private fun handleBiometricsUnlock() { + if (state.isBiometricsEnabled && !state.isBiometricsValid) { + biometricsEncryptionManager.setupBiometrics() + } + sendEvent(UnlockEvent.BiometricUnlock) + } + + private fun handleDismissDialog() { + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleBiometricsLockout() { + mutableStateFlow.update { + it.copy( + dialog = UnlockState.Dialog.Error( + message = R.string.too_many_failed_biometric_attempts.asText(), + ) + ) + } + } +} + +@Parcelize +data class UnlockState( + val isBiometricsEnabled: Boolean, + val isBiometricsValid: Boolean, + val dialog: Dialog?, +) : Parcelable { + + @Parcelize + sealed class Dialog : Parcelable { + data class Error( + val message: Text, + ) : Dialog() + + data object Loading : Dialog() + } +} + +sealed class UnlockEvent { + + data object BiometricUnlock : UnlockEvent() + + data class ShowToast( + val message: Text, + ) : UnlockEvent() + +} + +sealed class UnlockAction { + data object DismissDialog : UnlockAction() + + data object BiometricsLockout : UnlockAction() + + data object BiometricsUnlock : UnlockAction() + + sealed class Internal { + data class ReceiveUnlockResult( + val unlockResult: UnlockResult, + ) : Internal() + } +} diff --git a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt new file mode 100644 index 000000000..d64d2942b --- /dev/null +++ b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/button/BitwardenOutlinedButton.kt @@ -0,0 +1,65 @@ +package com.bitwarden.authenticator.ui.platform.components.button + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +/** + * Represents a Bitwarden-styled filled [OutlinedButton]. + * + * @param label The label for the button. + * @param onClick The callback when the button is clicked. + * @param modifier The [Modifier] to be applied to the button. + * @param isEnabled Whether or not the button is enabled. + */ +@Composable +fun BitwardenOutlinedButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + OutlinedButton( + onClick = onClick, + modifier = modifier + .semantics(mergeDescendants = true) { }, + enabled = isEnabled, + contentPadding = PaddingValues( + vertical = 10.dp, + horizontal = 24.dp, + ), + colors = ButtonDefaults.outlinedButtonColors(), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Preview +@Composable +private fun BitwardenOutlinedButton_preview_isEnabled() { + BitwardenOutlinedButton( + label = "Label", + onClick = {}, + isEnabled = true, + ) +} + +@Preview +@Composable +private fun BitwardenOutlinedButton_preview_isNotEnabled() { + BitwardenOutlinedButton( + label = "Label", + onClick = {}, + isEnabled = false, + ) +} 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 c6b5ffcf4..fae440a27 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 @@ -13,6 +13,9 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.bitwarden.authenticator.ui.auth.unlock.UNLOCK_ROUTE +import com.bitwarden.authenticator.ui.auth.unlock.navigateToUnlock +import com.bitwarden.authenticator.ui.auth.unlock.unlockDestination import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.AUTHENTICATOR_GRAPH_ROUTE import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.authenticatorGraph import com.bitwarden.authenticator.ui.authenticator.feature.authenticator.navigateToAuthenticatorGraph @@ -65,17 +68,17 @@ fun RootNavScreen( popEnterTransition = { toEnterTransition()(this) }, popExitTransition = { toExitTransition()(this) }, ) { - splashDestination( - onSplashScreenDismissed = { - viewModel.trySendAction(RootNavAction.Internal.SplashScreenDismissed) - }, - onExitApplication = onExitApplication, - ) + splashDestination() tutorialDestination( onTutorialFinished = { viewModel.trySendAction(RootNavAction.Internal.TutorialFinished) }, ) + unlockDestination( + onUnlocked = { + viewModel.trySendAction(RootNavAction.Internal.AppUnlocked) + } + ) authenticatorGraph( navController = navController, onNavigateBack = onExitApplication, @@ -83,9 +86,10 @@ fun RootNavScreen( } val targetRoute = when (state.navState) { - RootNavState.NavState.ItemListing -> AUTHENTICATOR_GRAPH_ROUTE RootNavState.NavState.Splash -> SPLASH_ROUTE + RootNavState.NavState.Locked -> UNLOCK_ROUTE RootNavState.NavState.Tutorial -> TUTORIAL_ROUTE + RootNavState.NavState.Unlocked -> AUTHENTICATOR_GRAPH_ROUTE } val currentRoute = navController.currentDestination?.rootLevelRoute() @@ -120,7 +124,11 @@ fun RootNavScreen( navController.navigateToTutorial(rootNavOptions) } - RootNavState.NavState.ItemListing -> { + RootNavState.NavState.Locked -> { + navController.navigateToUnlock(rootNavOptions) + } + + RootNavState.NavState.Unlocked -> { 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 ba106b2fc..25e230a77 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,11 +1,16 @@ 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 @@ -20,6 +25,15 @@ class RootNavViewModel @Inject constructor( ) ) { + init { + viewModelScope.launch { + settingsRepository.hasSeenWelcomeTutorialFlow + .map { RootNavAction.Internal.HasSeenWelcomeTutorialChange(it) } + .onEach(::sendAction) + .launchIn(viewModelScope) + } + } + override fun handleAction(action: RootNavAction) { when (action) { RootNavAction.BackStackUpdate -> { @@ -37,6 +51,10 @@ class RootNavViewModel @Inject constructor( RootNavAction.Internal.SplashScreenDismissed -> { handleSplashScreenDismissed() } + + RootNavAction.Internal.AppUnlocked -> { + handleAppUnlocked() + } } } @@ -45,8 +63,13 @@ class RootNavViewModel @Inject constructor( } private fun handleHasSeenWelcomeTutorialChange(hasSeenWelcomeGuide: Boolean) { + settingsRepository.hasSeenWelcomeTutorial = hasSeenWelcomeGuide if (hasSeenWelcomeGuide) { - mutableStateFlow.update { it.copy(navState = RootNavState.NavState.ItemListing) } + if (settingsRepository.isUnlockWithBiometricsEnabled) { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Locked) } + } else { + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } + } } else { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Tutorial) } } @@ -54,16 +77,22 @@ class RootNavViewModel @Inject constructor( private fun handleTutorialFinished() { settingsRepository.hasSeenWelcomeTutorial = true - mutableStateFlow.update { it.copy(navState = RootNavState.NavState.ItemListing) } + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } } private fun handleSplashScreenDismissed() { if (settingsRepository.hasSeenWelcomeTutorial) { - mutableStateFlow.update { it.copy(navState = RootNavState.NavState.ItemListing) } + mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Unlocked) } } else { mutableStateFlow.update { it.copy(navState = RootNavState.NavState.Tutorial) } } } + + private fun handleAppUnlocked() { + mutableStateFlow.update { + it.copy(navState = RootNavState.NavState.Unlocked) + } + } } /** @@ -83,6 +112,9 @@ data class RootNavState( @Parcelize data object Splash : NavState() + @Parcelize + data object Locked : NavState() + /** * App should display the Tutorial nav graph. */ @@ -93,7 +125,7 @@ data class RootNavState( * App should display the Account List nav graph. */ @Parcelize - data object ItemListing : NavState() + data object Unlocked : NavState() } } @@ -115,6 +147,8 @@ sealed class RootNavAction { data object TutorialFinished : Internal() + data object AppUnlocked : 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/splash/SplashNavigation.kt b/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashNavigation.kt index d981c0b26..bbca8c418 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,16 +10,8 @@ const val SPLASH_ROUTE: String = "splash" /** * Add splash destinations to the nav graph. */ -fun NavGraphBuilder.splashDestination( - onSplashScreenDismissed: () -> Unit, - onExitApplication: () -> Unit, -) { - composable(SPLASH_ROUTE) { - SplashScreen( - onNavigateToAuthenticator = onSplashScreenDismissed, - onExitApplication = onExitApplication, - ) - } +fun NavGraphBuilder.splashDestination() { + composable(SPLASH_ROUTE) { SplashScreen() } } /** 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 3d45dd9b6..ee3550542 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,119 +1,14 @@ 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( - 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 - } - +fun SplashScreen() { 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 deleted file mode 100644 index f27a25208..000000000 --- a/app/src/main/kotlin/com/bitwarden/authenticator/ui/platform/feature/splash/SplashViewModel.kt +++ /dev/null @@ -1,148 +0,0 @@ -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/res/drawable/ic_logo_horizontal.xml b/app/src/main/res/drawable/ic_logo_horizontal.xml new file mode 100644 index 000000000..463bd0b9d --- /dev/null +++ b/app/src/main/res/drawable/ic_logo_horizontal.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 046409a98..87f96af2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,4 +93,6 @@ Unlock with %1$s Biometrics Security + Use biometrics to unlock + Too many failed biometrics attempts.