mirror of
https://github.com/bitwarden/android.git
synced 2026-08-28 14:47:37 -05:00
Allow users to enable biometric unlock from settings (#44)
This commit is contained in:
@@ -38,7 +38,8 @@ class MainActivity : AppCompatActivity() {
|
||||
theme = state.theme
|
||||
) {
|
||||
RootNavScreen(
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false }
|
||||
onSplashScreenRemoved = { shouldShowSplashScreen = false },
|
||||
onExitApplication = { finishAffinity() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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()
|
||||
},
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+31
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+52
-32
@@ -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.
|
||||
*/
|
||||
|
||||
+80
-1
@@ -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
|
||||
|
||||
+111
-5
@@ -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
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+106
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+148
@@ -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()
|
||||
}
|
||||
+2
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user