Skip to content

Commit

Permalink
Create dedicated unlock screen
Browse files Browse the repository at this point in the history
  • Loading branch information
SaintPatrck committed Apr 25, 2024
1 parent 7e93c1a commit 4c710f0
Show file tree
Hide file tree
Showing 12 changed files with 483 additions and 276 deletions.
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<UnlockState, UnlockEvent, UnlockAction>(
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()
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
}
Loading

0 comments on commit 4c710f0

Please sign in to comment.