diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt index 368f6c994..df5e2bc1e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api 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.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -210,6 +212,12 @@ fun VaultUnlockScreen( .padding(horizontal = 16.dp) .fillMaxWidth(), autoFocus = state.showKeyboard, + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions( + onDone = remember(viewModel) { + { viewModel.trySendAction(VaultUnlockAction.UnlockClick) } + }, + ), ) Spacer(modifier = Modifier.height(24.dp)) Text( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index 30a254677..9e9a5f9e7 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -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.model.VaultUnlockResult 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.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.BackgroundEvent @@ -200,6 +201,18 @@ class VaultUnlockViewModel @Inject constructor( private fun handleUnlockClick() { 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) } viewModelScope.launch { val vaultUnlockResult = when (state.vaultUnlockType) { diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensions.kt index 499bf953e..5a466acbf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensions.kt @@ -72,3 +72,9 @@ val VaultUnlockType.unlockScreenKeyboardType: KeyboardType VaultUnlockType.MASTER_PASSWORD -> KeyboardType.Password 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) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt index ae26c1242..2b3f808bf 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt @@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.platform.components.field import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon 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 * the password field. * @param imeAction the preferred IME action for the keyboard to have. + * @param keyboardActions the callbacks of keyboard actions. */ @Composable fun BitwardenPasswordField( @@ -68,6 +70,7 @@ fun BitwardenPasswordField( autoFocus: Boolean = false, keyboardType: KeyboardType = KeyboardType.Password, imeAction: ImeAction = ImeAction.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, ) { val focusRequester = remember { FocusRequester() } OutlinedTextField( @@ -89,6 +92,7 @@ fun BitwardenPasswordField( keyboardType = keyboardType, imeAction = imeAction, ), + keyboardActions = keyboardActions, supportingText = hint?.let { { Text( @@ -144,6 +148,7 @@ fun BitwardenPasswordField( * @param keyboardType The type of keyboard the user has access to when inputting values into * the password field. * @param imeAction the preferred IME action for the keyboard to have. + * @param keyboardActions the callbacks of keyboard actions. */ @Composable fun BitwardenPasswordField( @@ -159,6 +164,7 @@ fun BitwardenPasswordField( autoFocus: Boolean = false, keyboardType: KeyboardType = KeyboardType.Password, imeAction: ImeAction = ImeAction.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, ) { var showPassword by rememberSaveable { mutableStateOf(initialShowPassword) } BitwardenPasswordField( @@ -175,6 +181,7 @@ fun BitwardenPasswordField( autoFocus = autoFocus, keyboardType = keyboardType, imeAction = imeAction, + keyboardActions = keyboardActions, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt index 00ef23e7f..0cb369343 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockScreenTest.kt @@ -1,5 +1,7 @@ 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.assertIsEnabled 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.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performKeyPress import androidx.compose.ui.test.performScrollTo 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.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest @@ -418,6 +422,19 @@ class VaultUnlockScreenTest : BaseComposeTest() { 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 fun `state with input and without biometrics should request focus on input field`() { mutableStateFlow.update { it.copy(hideInput = false, isBiometricEnabled = false) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index 1a89b874a..eeba7f98e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -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.model.VaultUnlockResult 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.util.asText 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 fun `on UnlockClick for password unlock should display error dialog on AuthenticationError`() { 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 fun `on UnlockClick for PIN unlock should display error dialog on AuthenticationError`() { val pin = "1234" diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensionsTest.kt index 794bc7bbb..e09bd0bc8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/util/VaultUnlockTypeExtensionsTest.kt @@ -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, + ) + } + } }