[PM-42349] fix: use fixed-length password mask (#7293)

This commit is contained in:
Kai Jones authored and GitHub committed 2026-09-08 16:16:09 +00:00
1 parent d59fcfe088
commit 280b5edaa6
5 files changed
+203 -8

No files matched your search

@@ -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")
@@ -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,
@@ -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
},
@@ -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)
},
)
}
@@ -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<String>()
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<String>(), 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", "••••••••")
}
}