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..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 /** @@ -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 e976af17430..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,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,9 @@ 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 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 new file mode 100644 index 00000000000..ef784af4d55 --- /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", "••••••••") + } +}