Allow users to enable biometric unlock from settings (#44)

This commit is contained in:
Patrick Honkonen
2024-04-24 08:40:54 -05:00
committed by GitHub
parent 9e86332c5e
commit f191f02296
14 changed files with 556 additions and 66 deletions
@@ -38,7 +38,8 @@ class MainActivity : AppCompatActivity() {
theme = state.theme
) {
RootNavScreen(
onSplashScreenRemoved = { shouldShowSplashScreen = false }
onSplashScreenRemoved = { shouldShowSplashScreen = false },
onExitApplication = { finishAffinity() }
)
}
}
@@ -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()
},
@@ -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,
@@ -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,
@@ -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,
@@ -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)
}
}
}
}
@@ -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<RootNavState, Unit, RootNavAction>(
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.
*/
@@ -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
@@ -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() {
}
}
}
@@ -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,
)
}
}
/**
@@ -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
}
}
@@ -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<SplashState, SplashEvent, SplashAction>(
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()
}
@@ -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<TutorialState, TutorialEvent, TutorialAction>(
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)
}
}
+3
View File
@@ -90,4 +90,7 @@
<string name="file_format">File format</string>
<string name="export_vault_failure">There was a problem exporting your vault. If the problem persists, you\\\'ll need to export from the web vault.</string>
<string name="export_success">Data exported successfully</string>
<string name="unlock_with">Unlock with %1$s</string>
<string name="biometrics">Biometrics</string>
<string name="security">Security</string>
</resources>