mirror of
https://github.com/bitwarden/android.git
synced 2025-02-16 11:59:57 +03:00
[PM-10685] Support keyboard Done event as CTA Unlock on Pin\Master Password unlock screen (#3691)
This commit is contained in:
parent
38e693f92c
commit
88b40cfd10
7 changed files with 112 additions and 0 deletions
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
@ -31,6 +32,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
@ -210,6 +212,12 @@ fun VaultUnlockScreen(
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
autoFocus = state.showKeyboard,
|
autoFocus = state.showKeyboard,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = remember(viewModel) {
|
||||||
|
{ viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|
|
@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
|
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
|
||||||
|
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.emptyInputDialogMessage
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenErrorMessage
|
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenErrorMessage
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent
|
import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent
|
||||||
|
@ -200,6 +201,18 @@ class VaultUnlockViewModel @Inject constructor(
|
||||||
|
|
||||||
private fun handleUnlockClick() {
|
private fun handleUnlockClick() {
|
||||||
val activeUserId = authRepository.activeUserId ?: return
|
val activeUserId = authRepository.activeUserId ?: return
|
||||||
|
|
||||||
|
if (state.input.isEmpty()) {
|
||||||
|
mutableStateFlow.update {
|
||||||
|
it.copy(
|
||||||
|
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||||
|
it.vaultUnlockType.emptyInputDialogMessage,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) }
|
mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val vaultUnlockResult = when (state.vaultUnlockType) {
|
val vaultUnlockResult = when (state.vaultUnlockType) {
|
||||||
|
|
|
@ -72,3 +72,9 @@ val VaultUnlockType.unlockScreenKeyboardType: KeyboardType
|
||||||
VaultUnlockType.MASTER_PASSWORD -> KeyboardType.Password
|
VaultUnlockType.MASTER_PASSWORD -> KeyboardType.Password
|
||||||
VaultUnlockType.PIN -> KeyboardType.Number
|
VaultUnlockType.PIN -> KeyboardType.Number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message to show when user try to unlock vault with empty or blank input.
|
||||||
|
*/
|
||||||
|
val VaultUnlockType.emptyInputDialogMessage: Text
|
||||||
|
get() = R.string.validation_field_required.asText(unlockScreenInputLabel)
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.components.field
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
@ -52,6 +53,7 @@ import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
|
||||||
* @param keyboardType The type of keyboard the user has access to when inputting values into
|
* @param keyboardType The type of keyboard the user has access to when inputting values into
|
||||||
* the password field.
|
* the password field.
|
||||||
* @param imeAction the preferred IME action for the keyboard to have.
|
* @param imeAction the preferred IME action for the keyboard to have.
|
||||||
|
* @param keyboardActions the callbacks of keyboard actions.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BitwardenPasswordField(
|
fun BitwardenPasswordField(
|
||||||
|
@ -68,6 +70,7 @@ fun BitwardenPasswordField(
|
||||||
autoFocus: Boolean = false,
|
autoFocus: Boolean = false,
|
||||||
keyboardType: KeyboardType = KeyboardType.Password,
|
keyboardType: KeyboardType = KeyboardType.Password,
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
) {
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
|
@ -89,6 +92,7 @@ fun BitwardenPasswordField(
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
imeAction = imeAction,
|
imeAction = imeAction,
|
||||||
),
|
),
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
supportingText = hint?.let {
|
supportingText = hint?.let {
|
||||||
{
|
{
|
||||||
Text(
|
Text(
|
||||||
|
@ -144,6 +148,7 @@ fun BitwardenPasswordField(
|
||||||
* @param keyboardType The type of keyboard the user has access to when inputting values into
|
* @param keyboardType The type of keyboard the user has access to when inputting values into
|
||||||
* the password field.
|
* the password field.
|
||||||
* @param imeAction the preferred IME action for the keyboard to have.
|
* @param imeAction the preferred IME action for the keyboard to have.
|
||||||
|
* @param keyboardActions the callbacks of keyboard actions.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun BitwardenPasswordField(
|
fun BitwardenPasswordField(
|
||||||
|
@ -159,6 +164,7 @@ fun BitwardenPasswordField(
|
||||||
autoFocus: Boolean = false,
|
autoFocus: Boolean = false,
|
||||||
keyboardType: KeyboardType = KeyboardType.Password,
|
keyboardType: KeyboardType = KeyboardType.Password,
|
||||||
imeAction: ImeAction = ImeAction.Default,
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
) {
|
) {
|
||||||
var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) }
|
var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) }
|
||||||
BitwardenPasswordField(
|
BitwardenPasswordField(
|
||||||
|
@ -175,6 +181,7 @@ fun BitwardenPasswordField(
|
||||||
autoFocus = autoFocus,
|
autoFocus = autoFocus,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
imeAction = imeAction,
|
imeAction = imeAction,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
|
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
|
||||||
|
|
||||||
|
import androidx.compose.ui.input.key.KeyEvent
|
||||||
|
import androidx.compose.ui.input.key.NativeKeyEvent
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsEnabled
|
import androidx.compose.ui.test.assertIsEnabled
|
||||||
import androidx.compose.ui.test.assertIsFocused
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
|
@ -12,8 +14,10 @@ import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performKeyPress
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import androidx.compose.ui.test.performTextInput
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.compose.ui.test.requestFocus
|
||||||
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
import com.x8bit.bitwarden.data.auth.repository.model.VaultUnlockType
|
||||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||||
|
@ -418,6 +422,19 @@ class VaultUnlockScreenTest : BaseComposeTest() {
|
||||||
verify { viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
|
verify { viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keyboard Done event should send UnlockClick action`() {
|
||||||
|
val keyEvent = KeyEvent(
|
||||||
|
NativeKeyEvent(NativeKeyEvent.ACTION_DOWN, NativeKeyEvent.KEYCODE_ENTER),
|
||||||
|
)
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithText("Master password")
|
||||||
|
.performScrollTo()
|
||||||
|
.requestFocus()
|
||||||
|
.performKeyPress(keyEvent)
|
||||||
|
verify { viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `state with input and without biometrics should request focus on input field`() {
|
fun `state with input and without biometrics should request focus on input field`() {
|
||||||
mutableStateFlow.update { it.copy(hideInput = false, isBiometricEnabled = false) }
|
mutableStateFlow.update { it.copy(hideInput = false, isBiometricEnabled = false) }
|
||||||
|
|
|
@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFl
|
||||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||||
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
|
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
|
||||||
|
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenInputLabel
|
||||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||||
|
@ -461,6 +462,28 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on UnlockClick for empty password should display error dialog`() {
|
||||||
|
val password = ""
|
||||||
|
val initialState = DEFAULT_STATE.copy(
|
||||||
|
input = password,
|
||||||
|
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
|
||||||
|
)
|
||||||
|
val viewModel = createViewModel(state = initialState)
|
||||||
|
|
||||||
|
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
||||||
|
assertEquals(
|
||||||
|
initialState.copy(
|
||||||
|
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||||
|
R.string.validation_field_required.asText(
|
||||||
|
initialState.vaultUnlockType.unlockScreenInputLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on UnlockClick for password unlock should display error dialog on AuthenticationError`() {
|
fun `on UnlockClick for password unlock should display error dialog on AuthenticationError`() {
|
||||||
val password = "abcd1234"
|
val password = "abcd1234"
|
||||||
|
@ -598,6 +621,28 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `on UnlockClick for empty PIN should display error dialog`() {
|
||||||
|
val password = ""
|
||||||
|
val initialState = DEFAULT_STATE.copy(
|
||||||
|
input = password,
|
||||||
|
vaultUnlockType = VaultUnlockType.PIN,
|
||||||
|
)
|
||||||
|
val viewModel = createViewModel(state = initialState)
|
||||||
|
|
||||||
|
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
||||||
|
assertEquals(
|
||||||
|
initialState.copy(
|
||||||
|
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||||
|
R.string.validation_field_required.asText(
|
||||||
|
initialState.vaultUnlockType.unlockScreenInputLabel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
viewModel.stateFlow.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `on UnlockClick for PIN unlock should display error dialog on AuthenticationError`() {
|
fun `on UnlockClick for PIN unlock should display error dialog on AuthenticationError`() {
|
||||||
val pin = "1234"
|
val pin = "1234"
|
||||||
|
|
|
@ -77,4 +77,20 @@ class VaultUnlockTypeExtensionsTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `emptyInputDialogMessage should return the correct title for each type`() {
|
||||||
|
mapOf(
|
||||||
|
VaultUnlockType.MASTER_PASSWORD to R.string.validation_field_required.asText(
|
||||||
|
R.string.master_password.asText(),
|
||||||
|
),
|
||||||
|
VaultUnlockType.PIN to R.string.validation_field_required.asText(R.string.pin.asText()),
|
||||||
|
)
|
||||||
|
.forEach { (type, expected) ->
|
||||||
|
assertEquals(
|
||||||
|
expected,
|
||||||
|
type.emptyInputDialogMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue