From 280b5edaa6adc674d98bf020db4cde697df1dad4 Mon Sep 17 00:00:00 2001 From: Kai Jones <19639817+488315@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:16:09 +0000 Subject: [PATCH] [PM-42349] fix: use fixed-length password mask (#7293) --- .../vault/feature/item/VaultItemScreenTest.kt | 12 +- .../field/BitwardenHiddenPasswordField.kt | 4 +- .../field/BitwardenPasswordField.kt | 7 +- .../BitwardenPasswordVisualTransformation.kt | 46 ++++++ .../field/BitwardenPasswordFieldTest.kt | 142 ++++++++++++++++++ 5 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/BitwardenPasswordVisualTransformation.kt create mode 100644 ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt 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 6c5430578e..5091e5e562 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() @@ -2942,7 +2946,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithTextAfterScroll("Number") - .assertTextEquals("Number", "••••••") + .assertTextEquals("Number", "••••••••") .assertIsEnabled() composeTestRule .onNodeWithTextAfterScroll("Number") @@ -2982,7 +2986,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithTextAfterScroll("Number") - .assertTextEquals("Number", "••••••") + .assertTextEquals("Number", "••••••••") .assertIsEnabled() composeTestRule .onNodeWithTextAfterScroll("Number") @@ -3098,7 +3102,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithTextAfterScroll("Security code") - .assertTextEquals("Security code", "•••") + .assertTextEquals("Security code", "••••••••") .assertIsEnabled() composeTestRule .onNodeWithContentDescription("Copy security code") @@ -3135,7 +3139,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithTextAfterScroll("Security code") - .assertTextEquals("Security code", "•••") + .assertTextEquals("Security code", "••••••••") .assertIsEnabled() composeTestRule .onNodeWithContentDescription("Copy security code") 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 7aba062e05..d671e5b900 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 /** @@ -44,7 +44,7 @@ fun BitwardenHiddenPasswordField( label = label?.let { { Text(text = it) } }, 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 e976af1743..bf26833e41 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,6 +57,7 @@ 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 @@ -190,7 +190,10 @@ fun BitwardenPasswordField( } }, visualTransformation = when { - !showPassword -> PasswordVisualTransformation() + !showPassword -> passwordVisualTransformation( + staticCharacterCount = 8.takeIf { readOnly }, + ) + readOnly -> nonLetterColorVisualTransformation() else -> VisualTransformation.None }, 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 0000000000..30e1eab18a --- /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 new file mode 100644 index 0000000000..ef784af4d5 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordFieldTest.kt @@ -0,0 +1,142 @@ +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() { + + @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, + 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, + cardStyle = CardStyle.Full, + ) + } + } + + composeTestRule + .onNodeWithText("Password") + .assertTextEquals("Password", password) + } + + @Test + fun `read only hidden password always uses fixed length mask`() { + setTestContent { + BitwardenTheme { + BitwardenPasswordField( + label = "Password", + value = "12345", + showPassword = false, + showPasswordChange = { }, + onValueChange = { }, + readOnly = true, + cardStyle = CardStyle.Full, + ) + } + } + + 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", "•••••") + } + + @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, + cardStyle = CardStyle.Full, + ) + } + } + + composeTestRule.onNodeWithText("Password").performClick() + composeTestRule.waitForIdle() + + assertEquals(emptyList(), emitted) + } + + @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", "••••••••") + } +}