From 839a7ad0760d3609a0181940a7242ca28f377a9f Mon Sep 17 00:00:00 2001 From: Kai Jones Date: Mon, 24 Aug 2026 00:24:13 -0700 Subject: [PATCH 1/4] [PM-42337] fix: use fixed-length password mask --- .../feature/item/VaultItemLoginContent.kt | 1 + .../item/component/VaultItemCustomField.kt | 2 + .../vault/feature/item/VaultItemScreenTest.kt | 12 ++- .../field/BitwardenHiddenPasswordField.kt | 2 +- .../field/BitwardenPasswordField.kt | 19 +++- .../field/BitwardenPasswordFieldTest.kt | 97 +++++++++++++++++++ 6 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt index 5323be77054..35a5bf48e21 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -248,6 +248,7 @@ private fun PasswordField( showPassword = passwordData.isVisible, onValueChange = { }, readOnly = true, + useFixedLengthMask = true, singleLine = false, actions = { BitwardenStandardIconButton( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemCustomField.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemCustomField.kt index 00023f94ead..a617d15db0f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemCustomField.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemCustomField.kt @@ -52,6 +52,7 @@ fun CustomField( showPassword = customField.isVisible, onValueChange = { }, readOnly = true, + useFixedLengthMask = true, singleLine = false, showPasswordTestTag = "CustomFieldShowPasswordButton", passwordFieldTestTag = "CustomFieldValue", @@ -74,6 +75,7 @@ fun CustomField( showPassword = customField.isVisible, onValueChange = { }, readOnly = true, + useFixedLengthMask = true, singleLine = false, cardStyle = cardStyle, modifier = modifier, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 6c5430578e0..2934cb08b0d 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -972,6 +972,10 @@ class VaultItemScreenTest : BitwardenComposeTest() { ) } + composeTestRule + .onNodeWithTextAfterScroll(textField.name) + .assertTextEquals(textField.name, "••••••••") + composeTestRule .onNodeWithTextAfterScroll(textField.name) .onChildren() @@ -1152,7 +1156,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { viewState = EMPTY_LOGIN_VIEW_STATE.copy( type = EMPTY_LOGIN_TYPE.copy( passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "p@ssw0rd", + password = "correct horse battery staple", isVisible = false, canViewPassword = true, ), @@ -1180,7 +1184,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { viewState = EMPTY_LOGIN_VIEW_STATE.copy( type = EMPTY_LOGIN_TYPE.copy( passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "p@ssw0rd", + password = "correct horse battery staple", isVisible = true, canViewPassword = true, ), @@ -1191,7 +1195,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Password") - .assertTextEquals("Password", "p@ssw0rd") + .assertTextEquals("Password", "correct horse battery staple") .assertIsEnabled() composeTestRule .onNodeWithTextAfterScroll("Check password for data breaches") @@ -1208,7 +1212,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { viewState = EMPTY_LOGIN_VIEW_STATE.copy( type = EMPTY_LOGIN_TYPE.copy( passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "p@ssw0rd", + password = "correct horse battery staple", isVisible = true, canViewPassword = false, ), diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt index 7aba062e050..8bb5c03296f 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt @@ -42,7 +42,7 @@ fun BitwardenHiddenPasswordField( .nullableTestTag(tag = passwordFieldTestTag), textStyle = BitwardenTheme.typography.sensitiveInfoSmall, label = label?.let { { Text(text = it) } }, - value = value, + value = if (value.isNotEmpty()) MASKED_PASSWORD_VALUE else value, onValueChange = { }, visualTransformation = PasswordVisualTransformation(), singleLine = true, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt index e976af17430..b012b393409 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt @@ -62,6 +62,8 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme +internal const val MASKED_PASSWORD_VALUE = "********" + /** * Represents a Bitwarden-styled password field that hoists show/hide password state to the caller. * @@ -77,6 +79,8 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme * @param modifier Modifier for the composable. * @param helpData An optional help button to be displayed in the label. * @param readOnly `true` if the input should be read-only and not accept user interactions. + * @param useFixedLengthMask `true` to hide the underlying value length when the field is read-only + * and the password is hidden. * @param singleLine when `true`, this text field becomes a single line that horizontally scrolls * instead of wrapping onto multiple lines. * @param showPasswordTestTag The test tag to be used on the show password button (testing tool). @@ -107,6 +111,7 @@ fun BitwardenPasswordField( modifier: Modifier = Modifier, helpData: BitwardenHelpButtonData? = null, readOnly: Boolean = false, + useFixedLengthMask: Boolean = false, singleLine: Boolean = true, showPasswordTestTag: String? = null, supportingContentPadding: PaddingValues = PaddingValues(vertical = 12.dp, horizontal = 16.dp), @@ -120,8 +125,14 @@ fun BitwardenPasswordField( actions: @Composable (RowScope.() -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } - var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } - val textFieldValue = textFieldValueState.copy(text = value) + val shouldUseFixedLengthMask = readOnly && useFixedLengthMask && !showPassword + val displayedValue = if (shouldUseFixedLengthMask && value.isNotEmpty()) { + MASKED_PASSWORD_VALUE + } else { + value + } + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = displayedValue)) } + val textFieldValue = textFieldValueState.copy(text = displayedValue) SideEffect { if (textFieldValue.selection != textFieldValueState.selection || textFieldValue.composition != textFieldValueState.composition @@ -265,6 +276,8 @@ fun BitwardenPasswordField( * @param modifier Modifier for the composable. * @param helpData An optional help button to be displayed in the label. * @param readOnly `true` if the input should be read-only and not accept user interactions. + * @param useFixedLengthMask `true` to hide the underlying value length when the field is read-only + * and the password is hidden. * @param singleLine when `true`, this text field becomes a single line that horizontally scrolls * instead of wrapping onto multiple lines. * @param supportingText An optional supporting text that will appear below the text input. @@ -294,6 +307,7 @@ fun BitwardenPasswordField( modifier: Modifier = Modifier, helpData: BitwardenHelpButtonData? = null, readOnly: Boolean = false, + useFixedLengthMask: Boolean = false, singleLine: Boolean = true, supportingText: String? = null, showPasswordTestTag: String? = null, @@ -316,6 +330,7 @@ fun BitwardenPasswordField( modifier = modifier, helpData = helpData, readOnly = readOnly, + useFixedLengthMask = useFixedLengthMask, singleLine = singleLine, supportingContent = supportingText?.let { { diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt new file mode 100644 index 00000000000..f58dc23e24a --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt @@ -0,0 +1,97 @@ +package com.bitwarden.ui.platform.components.field + +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithText +import com.bitwarden.ui.platform.base.BaseComposeTest +import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.theme.BitwardenTheme +import org.junit.Test + +class BitwardenPasswordFieldTest : BaseComposeTest() { + + @Test + fun `read only hidden password uses fixed length mask`() { + val password = "correct horse battery staple" + + setTestContent { + BitwardenTheme { + BitwardenPasswordField( + label = "Password", + value = password, + showPassword = false, + showPasswordChange = { }, + onValueChange = { }, + readOnly = true, + useFixedLengthMask = true, + cardStyle = CardStyle.Full, + ) + } + } + + composeTestRule + .onNodeWithText("Password") + .assertTextEquals("Password", "••••••••") + } + + @Test + fun `read only visible password shows actual value`() { + val password = "correct horse battery staple" + + setTestContent { + BitwardenTheme { + BitwardenPasswordField( + label = "Password", + value = password, + showPassword = true, + showPasswordChange = { }, + onValueChange = { }, + readOnly = true, + useFixedLengthMask = true, + cardStyle = CardStyle.Full, + ) + } + } + + composeTestRule + .onNodeWithText("Password") + .assertTextEquals("Password", password) + } + + @Test + fun `read only hidden password preserves value length by default`() { + setTestContent { + BitwardenTheme { + BitwardenPasswordField( + label = "Password", + value = "12345", + showPassword = false, + showPasswordChange = { }, + onValueChange = { }, + readOnly = true, + cardStyle = CardStyle.Full, + ) + } + } + + composeTestRule + .onNodeWithText("Password") + .assertTextEquals("Password", "•••••") + } + + @Test + fun `non interactable hidden password uses fixed length mask`() { + setTestContent { + BitwardenTheme { + BitwardenHiddenPasswordField( + label = "Password", + value = "correct horse battery staple", + cardStyle = CardStyle.Full, + ) + } + } + + composeTestRule + .onNodeWithText("Password") + .assertTextEquals("Password", "••••••••") + } +} From 06742ac60bf51f9c6c771d1df7f22140d463de1a Mon Sep 17 00:00:00 2001 From: Kai Jones Date: Mon, 24 Aug 2026 02:45:31 -0700 Subject: [PATCH 2/4] [PM-42337] fix: prevent masked value emission --- .../field/BitwardenPasswordField.kt | 4 ++- .../field/BitwardenPasswordFieldTest.kt | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt index b012b393409..7894c375fbe 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt @@ -151,7 +151,9 @@ fun BitwardenPasswordField( TextToolbarType.NONE -> BitwardenEmptyTextToolbar } - var lastTextValue by remember(value) { mutableStateOf(value = value) } + var lastTextValue by remember(displayedValue) { + mutableStateOf(value = displayedValue) + } CompositionLocalProvider(value = LocalTextToolbar provides textToolbar) { Column( modifier = modifier diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt index f58dc23e24a..96f483586ec 100644 --- a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt @@ -2,9 +2,11 @@ package com.bitwarden.ui.platform.components.field import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import com.bitwarden.ui.platform.base.BaseComposeTest import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.theme.BitwardenTheme +import org.junit.Assert.assertEquals import org.junit.Test class BitwardenPasswordFieldTest : BaseComposeTest() { @@ -78,6 +80,31 @@ class BitwardenPasswordFieldTest : BaseComposeTest() { .assertTextEquals("Password", "•••••") } + @Test + fun `tapping a masked read only field does not emit the mask`() { + val emitted = mutableListOf() + + setTestContent { + BitwardenTheme { + BitwardenPasswordField( + label = "Password", + value = "correct horse battery staple", + showPassword = false, + showPasswordChange = { }, + onValueChange = { emitted += it }, + readOnly = true, + useFixedLengthMask = true, + cardStyle = CardStyle.Full, + ) + } + } + + composeTestRule.onNodeWithText("Password").performClick() + composeTestRule.waitForIdle() + + assertEquals(emptyList(), emitted) + } + @Test fun `non interactable hidden password uses fixed length mask`() { setTestContent { From e1ff8aeed968729a84f834d20af79bb52a54cb37 Mon Sep 17 00:00:00 2001 From: Kai Jones Date: Thu, 3 Sep 2026 14:59:11 -0700 Subject: [PATCH 3/4] [PM-42337] refactor: add password visual transformation --- .../feature/item/VaultItemLoginContent.kt | 1 - .../item/component/VaultItemCustomField.kt | 2 - .../field/BitwardenHiddenPasswordField.kt | 6 +-- .../field/BitwardenPasswordField.kt | 29 +++--------- .../BitwardenPasswordVisualTransformation.kt | 46 +++++++++++++++++++ .../field/BitwardenPasswordFieldTest.kt | 26 +++++++++-- 6 files changed, 78 insertions(+), 32 deletions(-) create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/BitwardenPasswordVisualTransformation.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt index 35a5bf48e21..5323be77054 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -248,7 +248,6 @@ private fun PasswordField( showPassword = passwordData.isVisible, onValueChange = { }, readOnly = true, - useFixedLengthMask = true, singleLine = false, actions = { BitwardenStandardIconButton( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemCustomField.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemCustomField.kt index a617d15db0f..00023f94ead 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemCustomField.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemCustomField.kt @@ -52,7 +52,6 @@ fun CustomField( showPassword = customField.isVisible, onValueChange = { }, readOnly = true, - useFixedLengthMask = true, singleLine = false, showPasswordTestTag = "CustomFieldShowPasswordButton", passwordFieldTestTag = "CustomFieldValue", @@ -75,7 +74,6 @@ fun CustomField( showPassword = customField.isVisible, onValueChange = { }, readOnly = true, - useFixedLengthMask = true, singleLine = false, cardStyle = cardStyle, modifier = modifier, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt index 8bb5c03296f..d671e5b900a 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalTextToolbar import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.bitwarden.ui.platform.base.util.cardStyle @@ -16,6 +15,7 @@ import com.bitwarden.ui.platform.base.util.nullableTestTag import com.bitwarden.ui.platform.components.field.color.bitwardenTextFieldColors import com.bitwarden.ui.platform.components.field.toolbar.BitwardenEmptyTextToolbar import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.components.util.passwordVisualTransformation import com.bitwarden.ui.platform.theme.BitwardenTheme /** @@ -42,9 +42,9 @@ fun BitwardenHiddenPasswordField( .nullableTestTag(tag = passwordFieldTestTag), textStyle = BitwardenTheme.typography.sensitiveInfoSmall, label = label?.let { { Text(text = it) } }, - value = if (value.isNotEmpty()) MASKED_PASSWORD_VALUE else value, + value = value, onValueChange = { }, - visualTransformation = PasswordVisualTransformation(), + visualTransformation = passwordVisualTransformation(staticCharacterCount = 8), singleLine = true, enabled = false, readOnly = true, diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt index 7894c375fbe..b6621c6ba83 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt @@ -38,7 +38,6 @@ import androidx.compose.ui.platform.TextToolbar import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview @@ -58,12 +57,11 @@ import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.row.BitwardenRowOfActions import com.bitwarden.ui.platform.components.support.BitwardenSupportingContent import com.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation +import com.bitwarden.ui.platform.components.util.passwordVisualTransformation import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme -internal const val MASKED_PASSWORD_VALUE = "********" - /** * Represents a Bitwarden-styled password field that hoists show/hide password state to the caller. * @@ -79,8 +77,6 @@ internal const val MASKED_PASSWORD_VALUE = "********" * @param modifier Modifier for the composable. * @param helpData An optional help button to be displayed in the label. * @param readOnly `true` if the input should be read-only and not accept user interactions. - * @param useFixedLengthMask `true` to hide the underlying value length when the field is read-only - * and the password is hidden. * @param singleLine when `true`, this text field becomes a single line that horizontally scrolls * instead of wrapping onto multiple lines. * @param showPasswordTestTag The test tag to be used on the show password button (testing tool). @@ -111,7 +107,6 @@ fun BitwardenPasswordField( modifier: Modifier = Modifier, helpData: BitwardenHelpButtonData? = null, readOnly: Boolean = false, - useFixedLengthMask: Boolean = false, singleLine: Boolean = true, showPasswordTestTag: String? = null, supportingContentPadding: PaddingValues = PaddingValues(vertical = 12.dp, horizontal = 16.dp), @@ -125,14 +120,8 @@ fun BitwardenPasswordField( actions: @Composable (RowScope.() -> Unit)? = null, ) { val focusRequester = remember { FocusRequester() } - val shouldUseFixedLengthMask = readOnly && useFixedLengthMask && !showPassword - val displayedValue = if (shouldUseFixedLengthMask && value.isNotEmpty()) { - MASKED_PASSWORD_VALUE - } else { - value - } - var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = displayedValue)) } - val textFieldValue = textFieldValueState.copy(text = displayedValue) + var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) } + val textFieldValue = textFieldValueState.copy(text = value) SideEffect { if (textFieldValue.selection != textFieldValueState.selection || textFieldValue.composition != textFieldValueState.composition @@ -151,9 +140,7 @@ fun BitwardenPasswordField( TextToolbarType.NONE -> BitwardenEmptyTextToolbar } - var lastTextValue by remember(displayedValue) { - mutableStateOf(value = displayedValue) - } + var lastTextValue by remember(value) { mutableStateOf(value = value) } CompositionLocalProvider(value = LocalTextToolbar provides textToolbar) { Column( modifier = modifier @@ -203,7 +190,9 @@ fun BitwardenPasswordField( } }, visualTransformation = when { - !showPassword -> PasswordVisualTransformation() + !showPassword -> passwordVisualTransformation( + staticCharacterCount = 8.takeIf { readOnly }, + ) readOnly -> nonLetterColorVisualTransformation() else -> VisualTransformation.None }, @@ -278,8 +267,6 @@ fun BitwardenPasswordField( * @param modifier Modifier for the composable. * @param helpData An optional help button to be displayed in the label. * @param readOnly `true` if the input should be read-only and not accept user interactions. - * @param useFixedLengthMask `true` to hide the underlying value length when the field is read-only - * and the password is hidden. * @param singleLine when `true`, this text field becomes a single line that horizontally scrolls * instead of wrapping onto multiple lines. * @param supportingText An optional supporting text that will appear below the text input. @@ -309,7 +296,6 @@ fun BitwardenPasswordField( modifier: Modifier = Modifier, helpData: BitwardenHelpButtonData? = null, readOnly: Boolean = false, - useFixedLengthMask: Boolean = false, singleLine: Boolean = true, supportingText: String? = null, showPasswordTestTag: String? = null, @@ -332,7 +318,6 @@ fun BitwardenPasswordField( modifier = modifier, helpData = helpData, readOnly = readOnly, - useFixedLengthMask = useFixedLengthMask, singleLine = singleLine, supportingContent = supportingText?.let { { diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/BitwardenPasswordVisualTransformation.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/BitwardenPasswordVisualTransformation.kt new file mode 100644 index 00000000000..30e1eab18a3 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/BitwardenPasswordVisualTransformation.kt @@ -0,0 +1,46 @@ +package com.bitwarden.ui.platform.components.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +/** + * Returns a [VisualTransformation] that masks text with [mask]. When [staticCharacterCount] is + * non-null, the transformed text always contains that number of mask characters. + */ +@Composable +fun passwordVisualTransformation( + mask: Char = '\u2022', + staticCharacterCount: Int? = null, +): VisualTransformation = + remember(mask, staticCharacterCount) { + BitwardenPasswordVisualTransformation( + mask = mask, + staticCharacterCount = staticCharacterCount, + ) + } + +private class BitwardenPasswordVisualTransformation( + private val mask: Char, + private val staticCharacterCount: Int?, +) : VisualTransformation { + override fun filter( + text: AnnotatedString, + ): TransformedText = TransformedText( + AnnotatedString( + mask.toString().repeat(n = staticCharacterCount ?: text.text.length), + ), + object : OffsetMapping { + override fun originalToTransformed( + offset: Int, + ): Int = staticCharacterCount?.let { offset.coerceAtMost(it) } ?: offset + + override fun transformedToOriginal( + offset: Int, + ): Int = offset.coerceAtMost(text.length) + }, + ) +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt index 96f483586ec..ef784af4d55 100644 --- a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt @@ -24,7 +24,6 @@ class BitwardenPasswordFieldTest : BaseComposeTest() { showPasswordChange = { }, onValueChange = { }, readOnly = true, - useFixedLengthMask = true, cardStyle = CardStyle.Full, ) } @@ -48,7 +47,6 @@ class BitwardenPasswordFieldTest : BaseComposeTest() { showPasswordChange = { }, onValueChange = { }, readOnly = true, - useFixedLengthMask = true, cardStyle = CardStyle.Full, ) } @@ -60,7 +58,7 @@ class BitwardenPasswordFieldTest : BaseComposeTest() { } @Test - fun `read only hidden password preserves value length by default`() { + fun `read only hidden password always uses fixed length mask`() { setTestContent { BitwardenTheme { BitwardenPasswordField( @@ -75,6 +73,27 @@ class BitwardenPasswordFieldTest : BaseComposeTest() { } } + composeTestRule + .onNodeWithText("Password") + .assertTextEquals("Password", "••••••••") + } + + @Test + fun `editable hidden password preserves value length`() { + setTestContent { + BitwardenTheme { + BitwardenPasswordField( + label = "Password", + value = "12345", + showPassword = false, + showPasswordChange = { }, + onValueChange = { }, + readOnly = false, + cardStyle = CardStyle.Full, + ) + } + } + composeTestRule .onNodeWithText("Password") .assertTextEquals("Password", "•••••") @@ -93,7 +112,6 @@ class BitwardenPasswordFieldTest : BaseComposeTest() { showPasswordChange = { }, onValueChange = { emitted += it }, readOnly = true, - useFixedLengthMask = true, cardStyle = CardStyle.Full, ) } From 57d6252d42898c73ba76f23941a47eb265fa858e Mon Sep 17 00:00:00 2001 From: Kai Jones Date: Fri, 4 Sep 2026 10:44:06 -0700 Subject: [PATCH 4/4] [PM-42337] fix: address review feedback --- .../ui/vault/feature/item/VaultItemScreenTest.kt | 8 ++++---- .../platform/components/field/BitwardenPasswordField.kt | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 2934cb08b0d..9370f4e2740 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -1156,7 +1156,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { viewState = EMPTY_LOGIN_VIEW_STATE.copy( type = EMPTY_LOGIN_TYPE.copy( passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "correct horse battery staple", + password = "p@ssw0rd", isVisible = false, canViewPassword = true, ), @@ -1184,7 +1184,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { viewState = EMPTY_LOGIN_VIEW_STATE.copy( type = EMPTY_LOGIN_TYPE.copy( passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "correct horse battery staple", + password = "p@ssw0rd", isVisible = true, canViewPassword = true, ), @@ -1195,7 +1195,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Password") - .assertTextEquals("Password", "correct horse battery staple") + .assertTextEquals("Password", "p@ssw0rd") .assertIsEnabled() composeTestRule .onNodeWithTextAfterScroll("Check password for data breaches") @@ -1212,7 +1212,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { viewState = EMPTY_LOGIN_VIEW_STATE.copy( type = EMPTY_LOGIN_TYPE.copy( passwordData = VaultItemState.ViewState.Content.ItemType.Login.PasswordData( - password = "correct horse battery staple", + password = "p@ssw0rd", isVisible = true, canViewPassword = false, ), diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt index b6621c6ba83..bf26833e412 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt @@ -193,6 +193,7 @@ fun BitwardenPasswordField( !showPassword -> passwordVisualTransformation( staticCharacterCount = 8.takeIf { readOnly }, ) + readOnly -> nonLetterColorVisualTransformation() else -> VisualTransformation.None },