PM-10621: Create common biometrics and pin unlock UI elements (#3696)

This commit is contained in:
David Perez 2024-08-07 14:50:40 -05:00 committed by GitHub
parent be534f940b
commit e598fe5714
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 268 additions and 208 deletions

View file

@ -0,0 +1,39 @@
package com.x8bit.bitwarden.ui.platform.components.toggle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.x8bit.bitwarden.R
/**
* Displays a switch for enabling or disabling unlock with biometrics functionality.
*
* @param isChecked Indicates that the switch should be checked or not.
* @param isBiometricsSupported Indicates if biometrics is supported and we should display the
* switch.
* @param onDisableBiometrics Callback invoked when the toggle has be turned off.
* @param onEnableBiometrics Callback invoked when the toggle has be turned on.
* @param modifier The [Modifier] to be applied to the switch.
*/
@Composable
fun BitwardenUnlockWithBiometricsSwitch(
isBiometricsSupported: Boolean,
isChecked: Boolean,
onDisableBiometrics: () -> Unit,
onEnableBiometrics: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!isBiometricsSupported) return
BitwardenWideSwitch(
modifier = modifier,
label = stringResource(id = R.string.unlock_with, stringResource(id = R.string.biometrics)),
isChecked = isChecked,
onCheckedChange = { toggled ->
if (toggled) {
onEnableBiometrics()
} else {
onDisableBiometrics()
}
},
)
}

View file

@ -0,0 +1,161 @@
package com.x8bit.bitwarden.ui.platform.components.toggle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.PinInputDialog
/**
* Displays a switch for enabling or disabling unlock with pin functionality.
*
* @param isUnlockWithPasswordEnabled Indicates whether or not the password unlocking is enabled.
* @param isUnlockWithPinEnabled Indicates whether or not the pin unlocking is enabled.
* @param onUnlockWithPinToggleAction Callback that is invoked when the current state of the switch
* changes.
* @param modifier The [Modifier] to be applied to the switch.
*/
@Suppress("LongMethod")
@Composable
fun BitwardenUnlockWithPinSwitch(
isUnlockWithPasswordEnabled: Boolean,
isUnlockWithPinEnabled: Boolean,
onUnlockWithPinToggleAction: (UnlockWithPinState) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowPinInputDialog by rememberSaveable { mutableStateOf(value = false) }
var shouldShowPinConfirmationDialog by rememberSaveable { mutableStateOf(value = false) }
var pin by remember { mutableStateOf(value = "") }
BitwardenWideSwitch(
label = stringResource(id = R.string.unlock_with_pin),
isChecked = isUnlockWithPinEnabled,
onCheckedChange = { isChecked ->
if (isChecked) {
onUnlockWithPinToggleAction(UnlockWithPinState.PendingEnabled)
shouldShowPinInputDialog = true
} else {
onUnlockWithPinToggleAction(UnlockWithPinState.Disabled)
}
},
modifier = modifier,
)
when {
shouldShowPinInputDialog -> {
PinInputDialog(
onCancelClick = {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(UnlockWithPinState.Disabled)
pin = ""
},
onSubmitClick = {
pin = it
if (pin.isNotEmpty()) {
shouldShowPinInputDialog = false
if (isUnlockWithPasswordEnabled) {
shouldShowPinConfirmationDialog = true
onUnlockWithPinToggleAction(UnlockWithPinState.PendingEnabled)
} else {
onUnlockWithPinToggleAction(
UnlockWithPinState.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = false,
),
)
}
} else {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(UnlockWithPinState.Disabled)
}
},
onDismissRequest = {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(UnlockWithPinState.Disabled)
pin = ""
},
)
}
shouldShowPinConfirmationDialog -> {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.unlock_with_pin),
message = stringResource(id = R.string.pin_require_master_password_restart),
confirmButtonText = stringResource(id = R.string.yes),
dismissButtonText = stringResource(id = R.string.no),
onConfirmClick = {
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
UnlockWithPinState.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = true,
),
)
pin = ""
},
onDismissClick = {
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
UnlockWithPinState.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = false,
),
)
pin = ""
},
onDismissRequest = {
// Dismissing the dialog is the same as requiring a master password
// confirmation.
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
UnlockWithPinState.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = true,
),
)
pin = ""
},
)
}
}
}
/**
* User toggled the unlock with pin switch.
*/
sealed class UnlockWithPinState {
/**
* Whether or not the action represents PIN unlocking being enabled.
*/
abstract val isUnlockWithPinEnabled: Boolean
/**
* The toggle was disabled.
*/
data object Disabled : UnlockWithPinState() {
override val isUnlockWithPinEnabled: Boolean get() = false
}
/**
* The toggle was enabled but the behavior is pending confirmation.
*/
data object PendingEnabled : UnlockWithPinState() {
override val isUnlockWithPinEnabled: Boolean get() = true
}
/**
* The toggle was enabled and the user's [pin] and [shouldRequireMasterPasswordOnRestart]
* preference were confirmed.
*/
data class Enabled(
val pin: String,
val shouldRequireMasterPasswordOnRestart: Boolean,
) : UnlockWithPinState() {
override val isUnlockWithPinEnabled: Boolean get() = true
}
}

View file

@ -52,7 +52,8 @@ import com.x8bit.bitwarden.ui.platform.components.row.BitwardenExternalLinkRow
import com.x8bit.bitwarden.ui.platform.components.row.BitwardenTextRow
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.text.BitwardenPolicyWarningText
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithPinSwitch
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager
import com.x8bit.bitwarden.ui.platform.composition.LocalIntentManager
@ -196,9 +197,9 @@ fun AccountSecurityScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
UnlockWithBiometricsRow(
isChecked = state.isUnlockWithBiometricsEnabled,
showBiometricsPrompt = showBiometricsPrompt,
BitwardenUnlockWithBiometricsSwitch(
isBiometricsSupported = biometricsManager.isBiometricsSupported,
isChecked = state.isUnlockWithBiometricsEnabled || showBiometricsPrompt,
onDisableBiometrics = remember(viewModel) {
{
viewModel.trySendAction(
@ -211,17 +212,16 @@ fun AccountSecurityScreen(
onEnableBiometrics = remember(viewModel) {
{ viewModel.trySendAction(AccountSecurityAction.EnableBiometricsClick) }
},
biometricsManager = biometricsManager,
modifier = Modifier
.testTag("UnlockWithBiometricsSwitch")
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
UnlockWithPinRow(
BitwardenUnlockWithPinSwitch(
isUnlockWithPasswordEnabled = state.isUnlockWithPasswordEnabled,
isUnlockWithPinEnabled = state.isUnlockWithPinEnabled,
onUnlockWithPinToggleAction = remember(viewModel) {
{ viewModel.trySendAction(it) }
{ viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle(it)) }
},
modifier = Modifier
.testTag("UnlockWithPinSwitch")
@ -380,149 +380,6 @@ private fun AccountSecurityDialogs(
}
}
@Composable
private fun UnlockWithBiometricsRow(
isChecked: Boolean,
showBiometricsPrompt: Boolean,
onDisableBiometrics: () -> Unit,
onEnableBiometrics: () -> Unit,
biometricsManager: BiometricsManager,
modifier: Modifier = Modifier,
) {
if (!biometricsManager.isBiometricsSupported) return
BitwardenWideSwitch(
modifier = modifier,
label = stringResource(
id = R.string.unlock_with,
stringResource(id = R.string.biometrics),
),
isChecked = isChecked || showBiometricsPrompt,
onCheckedChange = { toggled ->
if (toggled) {
onEnableBiometrics()
} else {
onDisableBiometrics()
}
},
)
}
@Suppress("LongMethod")
@Composable
private fun UnlockWithPinRow(
isUnlockWithPasswordEnabled: Boolean,
isUnlockWithPinEnabled: Boolean,
onUnlockWithPinToggleAction: (AccountSecurityAction.UnlockWithPinToggle) -> Unit,
modifier: Modifier = Modifier,
) {
var shouldShowPinInputDialog by rememberSaveable { mutableStateOf(false) }
var shouldShowPinConfirmationDialog by rememberSaveable { mutableStateOf(false) }
var pin by remember { mutableStateOf("") }
BitwardenWideSwitch(
label = stringResource(id = R.string.unlock_with_pin),
isChecked = isUnlockWithPinEnabled,
onCheckedChange = { isChecked ->
if (isChecked) {
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.PendingEnabled,
)
shouldShowPinInputDialog = true
} else {
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
)
}
},
modifier = modifier,
)
when {
shouldShowPinInputDialog -> {
PinInputDialog(
onCancelClick = {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
)
pin = ""
},
onSubmitClick = {
pin = it
if (pin.isNotEmpty()) {
shouldShowPinInputDialog = false
if (isUnlockWithPasswordEnabled) {
shouldShowPinConfirmationDialog = true
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.PendingEnabled,
)
} else {
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = false,
),
)
}
} else {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
)
}
},
onDismissRequest = {
shouldShowPinInputDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
)
pin = ""
},
)
}
shouldShowPinConfirmationDialog -> {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.unlock_with_pin),
message = stringResource(id = R.string.pin_require_master_password_restart),
confirmButtonText = stringResource(id = R.string.yes),
dismissButtonText = stringResource(id = R.string.no),
onConfirmClick = {
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = true,
),
)
pin = ""
},
onDismissClick = {
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = false,
),
)
pin = ""
},
onDismissRequest = {
// Dismissing the dialog is the same as requiring a master password
// confirmation.
shouldShowPinConfirmationDialog = false
onUnlockWithPinToggleAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = pin,
shouldRequireMasterPasswordOnRestart = true,
),
)
pin = ""
},
)
}
}
}
@Composable
private fun SessionTimeoutPolicyRow(
vaultTimeoutPolicyMinutes: Int?,

View file

@ -21,6 +21,7 @@ import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@ -258,21 +259,21 @@ class AccountSecurityViewModel @Inject constructor(
private fun handleUnlockWithPinToggle(action: AccountSecurityAction.UnlockWithPinToggle) {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = action.isUnlockWithPinEnabled)
it.copy(isUnlockWithPinEnabled = action.unlockWithPinState.isUnlockWithPinEnabled)
}
when (action) {
AccountSecurityAction.UnlockWithPinToggle.PendingEnabled -> Unit
AccountSecurityAction.UnlockWithPinToggle.Disabled -> {
when (val state = action.unlockWithPinState) {
UnlockWithPinState.PendingEnabled -> Unit
UnlockWithPinState.Disabled -> {
settingsRepository.clearUnlockPin()
validateVaultTimeoutAction()
}
is AccountSecurityAction.UnlockWithPinToggle.Enabled -> {
is UnlockWithPinState.Enabled -> {
settingsRepository.storeUnlockPin(
pin = action.pin,
pin = state.pin,
shouldRequireMasterPasswordOnRestart =
action.shouldRequireMasterPasswordOnRestart,
state.shouldRequireMasterPasswordOnRestart,
)
}
}
@ -563,37 +564,9 @@ sealed class AccountSecurityAction {
/**
* User toggled the unlock with pin switch.
*/
sealed class UnlockWithPinToggle : AccountSecurityAction() {
/**
* Whether or not the action represents PIN unlocking being enabled.
*/
abstract val isUnlockWithPinEnabled: Boolean
/**
* The toggle was disabled.
*/
data object Disabled : UnlockWithPinToggle() {
override val isUnlockWithPinEnabled: Boolean get() = false
}
/**
* The toggle was enabled but the behavior is pending confirmation.
*/
data object PendingEnabled : UnlockWithPinToggle() {
override val isUnlockWithPinEnabled: Boolean get() = true
}
/**
* The toggle was enabled and the user's [pin] and [shouldRequireMasterPasswordOnRestart]
* preference were confirmed.
*/
data class Enabled(
val pin: String,
val shouldRequireMasterPasswordOnRestart: Boolean,
) : UnlockWithPinToggle() {
override val isUnlockWithPinEnabled: Boolean get() = true
}
}
data class UnlockWithPinToggle(
val unlockWithPinState: UnlockWithPinState,
) : AccountSecurityAction()
/**
* Models actions that can be sent by the view model itself.

View file

@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeoutAction
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.intent.IntentManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
@ -244,7 +245,11 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.performScrollTo()
.performClick()
verify { viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle.Disabled) }
verify {
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle(UnlockWithPinState.Disabled),
)
}
}
@Suppress("MaxLineLength")
@ -285,7 +290,11 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
verify { viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle.PendingEnabled) }
verify {
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle(UnlockWithPinState.PendingEnabled),
)
}
}
@Suppress("MaxLineLength")
@ -304,7 +313,11 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle.Disabled) }
verify {
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle(UnlockWithPinState.Disabled),
)
}
composeTestRule.assertNoDialogExists()
}
@ -324,7 +337,11 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle.Disabled) }
verify {
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle(UnlockWithPinState.Disabled),
)
}
composeTestRule.assertNoDialogExists()
}
@ -368,7 +385,11 @@ class AccountSecurityScreenTest : BaseComposeTest() {
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
verify { viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle.PendingEnabled) }
verify {
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle(UnlockWithPinState.PendingEnabled),
)
}
}
@Suppress("MaxLineLength")
@ -398,9 +419,11 @@ class AccountSecurityScreenTest : BaseComposeTest() {
verify {
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = false,
AccountSecurityAction.UnlockWithPinToggle(
UnlockWithPinState.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = false,
),
),
)
}
@ -432,9 +455,11 @@ class AccountSecurityScreenTest : BaseComposeTest() {
verify {
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = false,
AccountSecurityAction.UnlockWithPinToggle(
UnlockWithPinState.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = false,
),
),
)
}
@ -467,9 +492,11 @@ class AccountSecurityScreenTest : BaseComposeTest() {
verify {
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = true,
AccountSecurityAction.UnlockWithPinToggle(
UnlockWithPinState.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = true,
),
),
)
}

View file

@ -23,6 +23,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.model.createMockPolicy
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@ -458,7 +459,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { settingsRepository.clearUnlockPin() } just runs
val viewModel = createViewModel(initialState = initialState)
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
AccountSecurityAction.UnlockWithPinToggle(UnlockWithPinState.Disabled),
)
assertEquals(
initialState.copy(isUnlockWithPinEnabled = false),
@ -478,7 +479,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
every { settingsRepository.vaultTimeoutAction = VaultTimeoutAction.LOGOUT } just runs
val viewModel = createViewModel(initialState = initialState)
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle.Disabled,
AccountSecurityAction.UnlockWithPinToggle(UnlockWithPinState.Disabled),
)
assertEquals(
initialState.copy(
@ -500,7 +501,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
)
val viewModel = createViewModel(initialState = initialState)
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle.PendingEnabled,
AccountSecurityAction.UnlockWithPinToggle(UnlockWithPinState.PendingEnabled),
)
assertEquals(
initialState.copy(isUnlockWithPinEnabled = true),
@ -518,9 +519,11 @@ class AccountSecurityViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(initialState = initialState)
viewModel.trySendAction(
AccountSecurityAction.UnlockWithPinToggle.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = true,
AccountSecurityAction.UnlockWithPinToggle(
UnlockWithPinState.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = true,
),
),
)
assertEquals(