diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryListItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryListItem.kt index 232f607946b..4b8087ac904 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryListItem.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryListItem.kt @@ -22,6 +22,8 @@ import com.bitwarden.ui.platform.base.util.withLineBreaksAtWidth import com.bitwarden.ui.platform.base.util.withVisualTransformation import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.components.util.compoundVisualTransformation +import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation import com.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString @@ -61,7 +63,10 @@ fun PasswordHistoryListItem( ) Text( text = formattedText.withVisualTransformation( - visualTransformation = nonLetterColorVisualTransformation(), + visualTransformation = compoundVisualTransformation( + nonLetterColorVisualTransformation(), + forceLtrVisualTransformation(), + ), ), style = textStyle, color = BitwardenTheme.colorScheme.text.primary, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt index 1ccf7a47d07..e093abbc7a9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import com.bitwarden.ui.platform.base.util.standardHorizontalMargin import com.bitwarden.ui.platform.base.util.toListItemCardStyle @@ -27,6 +28,7 @@ import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.text.BitwardenHyperTextLink +import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme @@ -149,6 +151,7 @@ fun VaultItemIdentityContent( index = identityState.propertyList.indexOf(element = ssn), dividerPadding = 0.dp, ), + forceLtr = true, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() @@ -171,6 +174,7 @@ fun VaultItemIdentityContent( index = identityState.propertyList.indexOf(element = passportNumber), dividerPadding = 0.dp, ), + forceLtr = true, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() @@ -193,6 +197,7 @@ fun VaultItemIdentityContent( index = identityState.propertyList.indexOf(element = licenseNumber), dividerPadding = 0.dp, ), + forceLtr = true, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() @@ -215,6 +220,7 @@ fun VaultItemIdentityContent( index = identityState.propertyList.indexOf(element = email), dividerPadding = 0.dp, ), + forceLtr = true, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() @@ -237,6 +243,7 @@ fun VaultItemIdentityContent( index = identityState.propertyList.indexOf(element = phone), dividerPadding = 0.dp, ), + forceLtr = true, modifier = Modifier .fillMaxWidth() .standardHorizontalMargin() @@ -422,6 +429,7 @@ private fun IdentityCopyField( onCopyClick: () -> Unit, cardStyle: CardStyle, modifier: Modifier = Modifier, + forceLtr: Boolean = false, ) { BitwardenTextField( label = label, @@ -439,6 +447,11 @@ private fun IdentityCopyField( }, textFieldTestTag = textFieldTestTag, cardStyle = cardStyle, + visualTransformation = if (forceLtr) { + forceLtrVisualTransformation() + } else { + VisualTransformation.None + }, modifier = modifier, ) } 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 7811236f0e5..e41acfdfd0c 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 @@ -32,6 +32,7 @@ import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.model.TooltipData import com.bitwarden.ui.platform.components.text.BitwardenClickableText import com.bitwarden.ui.platform.components.text.BitwardenHyperTextLink +import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme @@ -473,6 +474,7 @@ private fun TotpField( }, textFieldTestTag = "LoginTotpEntry", cardStyle = CardStyle.Full, + visualTransformation = forceLtrVisualTransformation(), modifier = modifier, ) } else { @@ -528,6 +530,7 @@ private fun UriField( }, textFieldTestTag = "LoginUriEntry", cardStyle = cardStyle, + visualTransformation = forceLtrVisualTransformation(), modifier = modifier, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt index e5eefc2b9b7..c11ca6ec62b 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryScreenTest.kt @@ -10,6 +10,8 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow +import com.bitwarden.ui.platform.components.util.LRO +import com.bitwarden.ui.platform.components.util.PDF import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.tools.feature.generator.model.GeneratorPasswordHistoryMode @@ -98,7 +100,7 @@ class PasswordHistoryScreenTest : BitwardenComposeTest() { ) } - composeTestRule.onNodeWithText(password.password).assertIsDisplayed() + composeTestRule.onNodeWithText("$LRO${password.password}$PDF").assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Copy").performClick() verify { @@ -164,6 +166,6 @@ class PasswordHistoryScreenTest : BitwardenComposeTest() { ) } - composeTestRule.onNodeWithText("Password1").assertIsDisplayed() + composeTestRule.onNodeWithText("${LRO}Password1$PDF").assertIsDisplayed() } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt index fa62106fe1d..d8135dc7180 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/addedit/VaultAddEditScreenTest.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.test.performTouchInput import androidx.core.net.toUri import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData +import com.bitwarden.ui.platform.components.util.LRO +import com.bitwarden.ui.platform.components.util.PDF import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.util.asText import com.bitwarden.ui.util.assertNoDialogExists @@ -684,7 +686,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { .performClick() composeTestRule .onNodeWithText("Password") - .assertTextEquals("Password", "p@ssw0rd") + .assertTextEquals("Password", "${LRO}p@ssw0rd$PDF") .assertIsEnabled() composeTestRule .onNodeWithContentDescription("Hide") @@ -1006,7 +1008,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Authenticator key") - .assertTextEquals("Authenticator key", "TestCode") + .assertTextEquals("Authenticator key", "${LRO}TestCode$PDF") .assertIsEnabled() mutableStateFlow.update { currentState -> @@ -1015,7 +1017,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithTextAfterScroll("Authenticator key") - .assertTextEquals("Authenticator key", "NewTestCode") + .assertTextEquals("Authenticator key", "${LRO}NewTestCode$PDF") mutableStateFlow.update { currentState -> updateLoginType(currentState) { copy(totp = null) } @@ -1042,7 +1044,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Authenticator key") - .assertTextEquals("Authenticator key", "TestCode") + .assertTextEquals("Authenticator key", "${LRO}TestCode$PDF") .assertIsEnabled() composeTestRule 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 5b1b1741a9e..ce0e8353235 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 @@ -28,6 +28,8 @@ import androidx.core.net.toUri import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData +import com.bitwarden.ui.platform.components.util.LRO +import com.bitwarden.ui.platform.components.util.PDF import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString @@ -1002,7 +1004,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Password") - .assertTextEquals("Password", "p@ssw0rd") + .assertTextEquals("Password", "${LRO}p@ssw0rd$PDF") .assertIsEnabled() composeTestRule .onNodeWithTextAfterScroll("Check password for data breaches") @@ -2799,7 +2801,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Number") - .assertTextEquals("Number", "number") + .assertTextEquals("Number", "${LRO}number$PDF") .assertIsEnabled() composeTestRule .onNodeWithTextAfterScroll("Number") @@ -2949,7 +2951,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { composeTestRule .onNodeWithText("Security code") - .assertTextEquals("Security code", "123") + .assertTextEquals("Security code", "${LRO}123$PDF") .assertIsEnabled() composeTestRule .onNodeWithContentDescription("Copy security code") 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 18842041baf..e8a6c66dd61 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 @@ -40,7 +40,6 @@ 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 import androidx.compose.ui.unit.dp import com.bitwarden.ui.platform.base.util.cardStyle @@ -56,6 +55,8 @@ import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.model.TooltipData import com.bitwarden.ui.platform.components.row.BitwardenRowOfActions import com.bitwarden.ui.platform.components.support.BitwardenSupportingContent +import com.bitwarden.ui.platform.components.util.compoundVisualTransformation +import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation import com.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString @@ -140,6 +141,18 @@ fun BitwardenPasswordField( TextToolbarType.NONE -> BitwardenEmptyTextToolbar } var lastTextValue by remember(value) { mutableStateOf(value = value) } + + val visualTransformation = when { + !showPassword -> PasswordVisualTransformation() + + readOnly -> compoundVisualTransformation( + nonLetterColorVisualTransformation(), + forceLtrVisualTransformation(), + ) + + else -> forceLtrVisualTransformation() + } + CompositionLocalProvider(value = LocalTextToolbar provides textToolbar) { Column( modifier = modifier @@ -191,11 +204,7 @@ fun BitwardenPasswordField( onValueChange(it.text) } }, - visualTransformation = when { - !showPassword -> PasswordVisualTransformation() - readOnly -> nonLetterColorVisualTransformation() - else -> VisualTransformation.None - }, + visualTransformation = visualTransformation, singleLine = singleLine, readOnly = readOnly, keyboardOptions = KeyboardOptions( diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformation.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformation.kt new file mode 100644 index 00000000000..9b2eb9e116b --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformation.kt @@ -0,0 +1,89 @@ +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 + +/** + * A [VisualTransformation] that chains multiple other [VisualTransformation]s. + * + * This is useful for applying multiple transformations to a text field. The + * transformations are applied in the order they are provided, with each transformation's + * output becoming the input for the next transformation. + * + * ## Example Usage + * + * Combining password masking with LTR direction enforcement: + * ```kotlin + * val visualTransformation = compoundVisualTransformation( + * PasswordVisualTransformation(), + * forceLtrVisualTransformation() + * ) + * TextField( + * value = password, + * visualTransformation = visualTransformation + * ) + * ``` + * + * Combining color transformation with LTR for readonly fields: + * ```kotlin + * val visualTransformation = compoundVisualTransformation( + * nonLetterColorVisualTransformation(), + * forceLtrVisualTransformation() + * ) + * ``` + * + * ## Important Notes + * + * - Offset mapping is correctly composed through all transformations + * - The order of transformations matters (first applied is first in the list) + * - Use [compoundVisualTransformation] function for proper `remember` optimization + * + * @param transformations The visual transformations to apply in order + * @see compoundVisualTransformation + * @see forceLtrVisualTransformation + */ +internal class CompoundVisualTransformation( + vararg val transformations: VisualTransformation, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + return transformations.fold( + TransformedText( + text, + OffsetMapping.Identity, + ), + ) { acc, transformation -> + val result = transformation.filter(acc.text) + + val composedMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val originalTransformed = acc.offsetMapping.originalToTransformed(offset) + return result.offsetMapping.originalToTransformed(originalTransformed) + } + + override fun transformedToOriginal(offset: Int): Int { + val resultOriginal = result.offsetMapping.transformedToOriginal(offset) + return acc.offsetMapping.transformedToOriginal(resultOriginal) + } + } + TransformedText(result.text, composedMapping) + } + } +} + +/** + * Remembers a [CompoundVisualTransformation] for the given [transformations]. + * + * This is an optimization to avoid creating a new [CompoundVisualTransformation] on every + * recomposition. + */ +@Composable +fun compoundVisualTransformation( + vararg transformations: VisualTransformation, +): VisualTransformation = + remember(*transformations) { + CompoundVisualTransformation(*transformations) + } diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformation.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformation.kt new file mode 100644 index 00000000000..e625a7f8e11 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformation.kt @@ -0,0 +1,81 @@ +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.buildAnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +// Unicode characters for forcing LTR direction +const val LRO = "\u202A" +const val PDF = "\u202C" + +/** + * A [VisualTransformation] that forces the output to have an LTR text direction. + * + * This transformation wraps text with Unicode directional control characters (LRO/PDF) + * to ensure left-to-right rendering regardless of the UI's locale or text direction. + * + * ## When to Use + * + * Apply this transformation to fields containing **standardized, technical data** that is + * always interpreted from left-to-right, regardless of locale: + * - Passwords and sensitive authentication data + * - Social Security Numbers (SSN) + * - Driver's license numbers + * - Passport numbers + * - Payment card numbers + * - Email addresses (technical format) + * - Phone numbers (standardized format) + * - URIs and technical identifiers + * + * ## When NOT to Use + * + * Do NOT apply this transformation to **locale-dependent text** that may legitimately + * use RTL scripts: + * - Personal names (may use Arabic, Hebrew, etc.) + * - Company names + * - Addresses + * - Usernames (user choice) + * - Notes and other free-form text + * + * ## Implementation Notes + * + * - Only applies LTR transformation when text is **visible** + * - Do NOT use with obscured text (e.g., password bullets) as masked characters + * are directionally neutral + * - Can be composed with other transformations using [compoundVisualTransformation] + * + * @see compoundVisualTransformation + */ +internal object ForceLtrVisualTransformation : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val forcedLtrText = buildAnnotatedString { + append(LRO) + append(text) + append(PDF) + } + + val offsetMapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + 1 + + override fun transformedToOriginal(offset: Int): Int = + (offset - 1).coerceIn(0, text.length) + } + + return TransformedText(forcedLtrText, offsetMapping) + } +} + +/** + * Remembers a [ForceLtrVisualTransformation] transformation. + * + * This is an optimization to avoid creating a new [ForceLtrVisualTransformation] on every + * recomposition. + */ +@Composable +fun forceLtrVisualTransformation(): VisualTransformation = remember { + ForceLtrVisualTransformation +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformationTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformationTest.kt new file mode 100644 index 00000000000..e6c1644b88e --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/CompoundVisualTransformationTest.kt @@ -0,0 +1,348 @@ +package com.bitwarden.ui.platform.components.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CompoundVisualTransformationTest { + + @Test + fun `compoundVisualTransformation with no transformations returns identity`() { + val text = AnnotatedString("test") + val transformation = CompoundVisualTransformation() + + val result = transformation.filter(text) + + assertEquals(text, result.text) + assertEquals(0, result.offsetMapping.originalToTransformed(0)) + assertEquals(4, result.offsetMapping.originalToTransformed(4)) + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) + assertEquals(4, result.offsetMapping.transformedToOriginal(4)) + } + + @Suppress("MaxLineLength") + @Test + fun `compoundVisualTransformation with single transformation behaves identically to that transformation`() { + val text = AnnotatedString("password") + val passwordTransformation = PasswordVisualTransformation() + + val singleResult = passwordTransformation.filter(text) + val compoundResult = CompoundVisualTransformation(passwordTransformation).filter(text) + + assertEquals(singleResult.text, compoundResult.text) + + // Test offset mapping equivalence for various offsets + for (offset in 0..text.length) { + assertEquals( + singleResult.offsetMapping.originalToTransformed(offset), + compoundResult.offsetMapping.originalToTransformed(offset), + "originalToTransformed($offset) should match", + ) + } + + for (offset in 0..singleResult.text.length) { + assertEquals( + singleResult.offsetMapping.transformedToOriginal(offset), + compoundResult.offsetMapping.transformedToOriginal(offset), + "transformedToOriginal($offset) should match", + ) + } + } + + @Test + fun `compoundVisualTransformation applies transformations in order`() { + val text = AnnotatedString("abc") + + // First transformation: prepend "X" + val prependX = VisualTransformation { text -> + TransformedText( + AnnotatedString("X${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = (offset - 1).coerceAtLeast(0) + }, + ) + } + + // Second transformation: append "Y" + val appendY = VisualTransformation { text -> + TransformedText( + AnnotatedString("${text.text}Y"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int) = + offset.coerceAtMost(text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(prependX, appendY) + val result = compound.filter(text) + + // Expected: "XabcY" + assertEquals("XabcY", result.text.text) + } + + @Test + fun `compoundVisualTransformation offset mapping handles composition correctly`() { + val text = AnnotatedString("test") + + // Create a simple transformation that adds one character at start + val addPrefix = VisualTransformation { text -> + TransformedText( + AnnotatedString(">${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = + (offset - 1).coerceIn(0, text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(addPrefix, addPrefix) + val result = compound.filter(text) + + // After two applications: ">>test" + assertEquals(">>test", result.text.text) + + // Test originalToTransformed mapping + assertEquals( + 2, + result.offsetMapping.originalToTransformed(0), + "Original 0 -> Transformed 2", + ) + assertEquals( + 3, + result.offsetMapping.originalToTransformed(1), + "Original 1 -> Transformed 3", + ) + assertEquals( + 6, + result.offsetMapping.originalToTransformed(4), + "Original 4 -> Transformed 6", + ) + + // Test transformedToOriginal mapping + assertEquals( + 0, + result.offsetMapping.transformedToOriginal(0), + "Transformed 0 -> Original 0", + ) + assertEquals( + 0, + result.offsetMapping.transformedToOriginal(1), + "Transformed 1 -> Original 0", + ) + assertEquals( + 0, + result.offsetMapping.transformedToOriginal(2), + "Transformed 2 -> Original 0", + ) + assertEquals( + 1, + result.offsetMapping.transformedToOriginal(3), + "Transformed 3 -> Original 1", + ) + assertEquals( + 4, + result.offsetMapping.transformedToOriginal(6), + "Transformed 6 -> Original 4", + ) + } + + @Test + fun `compoundVisualTransformation transformedToOriginal handles edge case at start`() { + val text = AnnotatedString("abc") + + val addPrefix = VisualTransformation { text -> + TransformedText( + AnnotatedString("X${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = (offset - 1).coerceAtLeast(0) + }, + ) + } + + val compound = CompoundVisualTransformation(addPrefix, addPrefix) + val result = compound.filter(text) + + // Test offset 0 (should map back to original 0) + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) + } + + @Test + fun `compoundVisualTransformation transformedToOriginal handles edge case at end`() { + val text = AnnotatedString("abc") + + val addSuffix = VisualTransformation { text -> + TransformedText( + AnnotatedString("${text.text}X"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + override fun transformedToOriginal(offset: Int) = + offset.coerceAtMost(text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(addSuffix, addSuffix) + val result = compound.filter(text) + + // Result: "abcXX" (length 5) + // Testing beyond the original text length + assertEquals(3, result.offsetMapping.transformedToOriginal(3)) + assertEquals(3, result.offsetMapping.transformedToOriginal(4)) + assertEquals(3, result.offsetMapping.transformedToOriginal(5)) + } + + @Test + fun `compoundVisualTransformation with Password and ForceLtr transformations`() { + val text = AnnotatedString("password123") + + val passwordTransform = PasswordVisualTransformation() + val ltrTransform = ForceLtrVisualTransformation + + val compound = CompoundVisualTransformation(passwordTransform, ltrTransform) + val result = compound.filter(text) + + // Password transformation converts to bullets, then LTR adds control chars + // LTR adds LRO at start and PDF at end + val expectedLength = text.length + 2 // Original bullets + LRO + PDF + assertEquals(expectedLength, result.text.length) + + // Test offset mappings at various points + val mappings = listOf( + 0 to 1, // Original 0 should map to transformed 1 (after LRO) + 5 to 6, // Original 5 should map to transformed 6 + 11 to 12, // Original 11 (end) should map to transformed 12 + ) + + mappings.forEach { (original, transformed) -> + assertEquals( + transformed, + result.offsetMapping.originalToTransformed(original), + "Original $original should map to transformed $transformed", + ) + } + } + + @Test + fun `compoundVisualTransformation transformedToOriginal with out-of-bounds offset`() { + val text = AnnotatedString("test") + + // Transformation that adds characters at both ends + val wrapText = VisualTransformation { text -> + TransformedText( + AnnotatedString("[${text.text}]"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = + (offset - 1).coerceIn(0, text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(wrapText, wrapText) + val result = compound.filter(text) + + // Result should be "[[test]]" (length 8) + assertEquals("[[test]]", result.text.text) + + // Test with offsets beyond the transformed text length + // This tests the critical edge case mentioned in the review + val beyondEndOffset = result.text.length + 5 + val mappedOffset = result.offsetMapping.transformedToOriginal(beyondEndOffset) + + // Should be coerced to the original text length + assertEquals( + text.length, + mappedOffset, + "Out-of-bounds offset should be coerced to original text length", + ) + } + + @Test + fun `compoundVisualTransformation with empty string`() { + val text = AnnotatedString("") + + val addPrefix = VisualTransformation { text -> + TransformedText( + AnnotatedString(">${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = (offset - 1).coerceAtLeast(0) + }, + ) + } + + val compound = CompoundVisualTransformation(addPrefix) + val result = compound.filter(text) + + assertEquals(">", result.text.text) + assertEquals(1, result.offsetMapping.originalToTransformed(0)) + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) + assertEquals(0, result.offsetMapping.transformedToOriginal(1)) + } + + @Test + fun `compoundVisualTransformation preserves AnnotatedString spans`() { + val text = AnnotatedString.Builder().apply { + append("test") + }.toAnnotatedString() + + val identityTransform = VisualTransformation.None + val compound = CompoundVisualTransformation(identityTransform) + val result = compound.filter(text) + + assertEquals(text.text, result.text.text) + } + + @Test + fun `compoundVisualTransformation offset mapping is symmetric for identity`() { + val text = AnnotatedString("symmetric") + + val compound = CompoundVisualTransformation() + val result = compound.filter(text) + + // For identity transformation, offset mapping should be symmetric + for (offset in 0..text.length) { + val transformed = result.offsetMapping.originalToTransformed(offset) + val backToOriginal = result.offsetMapping.transformedToOriginal(transformed) + assertEquals( + offset, + backToOriginal, + "Round trip for offset $offset should return to original", + ) + } + } + + @Test + fun `compoundVisualTransformation with very long text`() { + val longText = "a".repeat(10000) + val text = AnnotatedString(longText) + + val addPrefix = VisualTransformation { text -> + TransformedText( + AnnotatedString(">${text.text}"), + object : OffsetMapping { + override fun originalToTransformed(offset: Int) = offset + 1 + override fun transformedToOriginal(offset: Int) = + (offset - 1).coerceIn(0, text.length) + }, + ) + } + + val compound = CompoundVisualTransformation(addPrefix) + val result = compound.filter(text) + + assertEquals(10001, result.text.length) + assertEquals(1, result.offsetMapping.originalToTransformed(0)) + assertEquals(10001, result.offsetMapping.originalToTransformed(10000)) + assertEquals(10000, result.offsetMapping.transformedToOriginal(10001)) + } +} diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformationTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformationTest.kt new file mode 100644 index 00000000000..d330737efb4 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/util/ForceLtrVisualTransformationTest.kt @@ -0,0 +1,256 @@ +package com.bitwarden.ui.platform.components.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ForceLtrVisualTransformationTest { + + @Test + fun `forceLtrVisualTransformation adds LRO and PDF characters`() { + val text = AnnotatedString("password") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}password$PDF", result.text.text) + assertEquals(10, result.text.length) // Original 8 + LRO + PDF + } + + @Test + fun `forceLtrVisualTransformation with empty string`() { + val text = AnnotatedString("") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("$LRO$PDF", result.text.text) + assertEquals(2, result.text.length) + } + + @Test + fun `forceLtrVisualTransformation originalToTransformed adds 1 to offset`() { + val text = AnnotatedString("test") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // LRO is inserted at position 0, so all original offsets shift by 1 + assertEquals(1, result.offsetMapping.originalToTransformed(0)) + assertEquals(2, result.offsetMapping.originalToTransformed(1)) + assertEquals(3, result.offsetMapping.originalToTransformed(2)) + assertEquals(4, result.offsetMapping.originalToTransformed(3)) + assertEquals(5, result.offsetMapping.originalToTransformed(4)) + } + + @Test + fun `forceLtrVisualTransformation transformedToOriginal subtracts 1 and coerces`() { + val text = AnnotatedString("test") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // Transformed text is "[LRO]test[PDF]" (length 6) + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) // LRO position + assertEquals(0, result.offsetMapping.transformedToOriginal(1)) // First char + assertEquals(1, result.offsetMapping.transformedToOriginal(2)) + assertEquals(2, result.offsetMapping.transformedToOriginal(3)) + assertEquals(3, result.offsetMapping.transformedToOriginal(4)) + assertEquals(4, result.offsetMapping.transformedToOriginal(5)) // PDF position + } + + @Test + fun `forceLtrVisualTransformation transformedToOriginal coerces negative offsets to 0`() { + val text = AnnotatedString("test") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // When transformedToOriginal receives 0, it computes (0 - 1) = -1 + // This should be coerced to 0 + assertEquals(0, result.offsetMapping.transformedToOriginal(0)) + } + + @Test + fun `forceLtrVisualTransformation transformedToOriginal coerces beyond text length`() { + val text = AnnotatedString("test") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // Transformed text length is 6, but we test with larger offset + val beyondEnd = 10 + val mappedOffset = result.offsetMapping.transformedToOriginal(beyondEnd) + + // Should be coerced to original text length (4) + assertEquals(4, mappedOffset) + } + + @Test + fun `forceLtrVisualTransformation preserves AnnotatedString spans`() { + val text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append("bold") + } + append("normal") + } + + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // The transformed text should still have spans (though offset by 1) + assertTrue(result.text.text.startsWith(LRO)) + assertTrue(result.text.text.endsWith(PDF)) + assertTrue(result.text.text.contains("boldnormal")) + } + + @Test + fun `forceLtrVisualTransformation with RTL characters`() { + // Arabic text "مرحبا" (Hello) + val text = AnnotatedString("مرحبا") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + // Should wrap RTL text with LTR control characters + assertEquals("${LRO}مرحبا$PDF", result.text.text) + assertTrue(result.text.text.startsWith(LRO)) + assertTrue(result.text.text.endsWith(PDF)) + } + + @Test + fun `forceLtrVisualTransformation with mixed LTR and RTL characters`() { + // Mixed English and Arabic + val text = AnnotatedString("Hello مرحبا World") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}Hello مرحبا World$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation with special characters`() { + val text = AnnotatedString("p@ssw0rd!#$%") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}p@ssw0rd!#$%$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation with numbers only`() { + val text = AnnotatedString("123456") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}123456$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation offset mapping is consistent at boundaries`() { + val text = AnnotatedString("abc") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // Test at start boundary + val startOriginal = 0 + val startTransformed = result.offsetMapping.originalToTransformed(startOriginal) + val backToStart = result.offsetMapping.transformedToOriginal(startTransformed) + assertEquals(startOriginal, backToStart) + + // Test at end boundary + val endOriginal = text.length + val endTransformed = result.offsetMapping.originalToTransformed(endOriginal) + val backToEnd = result.offsetMapping.transformedToOriginal(endTransformed) + assertEquals(endOriginal, backToEnd) + } + + @Test + fun `forceLtrVisualTransformation with very long text`() { + val longText = "a".repeat(10000) + val text = AnnotatedString(longText) + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + // Should handle long strings without issues + assertEquals(10002, result.text.length) // 10000 + LRO + PDF + assertTrue(result.text.text.startsWith(LRO)) + assertTrue(result.text.text.endsWith(PDF)) + + // Test offset mapping at various points in long text + assertEquals(5001, result.offsetMapping.originalToTransformed(5000)) + assertEquals(5000, result.offsetMapping.transformedToOriginal(5001)) + } + + @Test + fun `forceLtrVisualTransformation with whitespace`() { + val text = AnnotatedString(" ") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("$LRO $PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation with newlines`() { + val text = AnnotatedString("line1\nline2\nline3") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}line1\nline2\nline3$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation with existing unicode control characters`() { + // Text already containing direction control characters + val text = AnnotatedString("${LRO}test$PDF") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + // Should add additional control characters + assertEquals("$LRO${LRO}test$PDF$PDF", result.text.text) + } + + @Test + fun `forceLtrVisualTransformation round trip maintains offset relationships`() { + val text = AnnotatedString("password123") + val transformation = ForceLtrVisualTransformation + val result = transformation.filter(text) + + // For each original offset, going to transformed and back should preserve the offset + for (originalOffset in 0..text.length) { + val transformed = result.offsetMapping.originalToTransformed(originalOffset) + val backToOriginal = result.offsetMapping.transformedToOriginal(transformed) + assertEquals( + originalOffset, + backToOriginal, + "Round trip failed for offset $originalOffset", + ) + } + } + + @Test + fun `forceLtrVisualTransformation with single character`() { + val text = AnnotatedString("a") + val transformation = ForceLtrVisualTransformation + + val result = transformation.filter(text) + + assertEquals("${LRO}a$PDF", result.text.text) + assertEquals(3, result.text.length) + + // Test offset mappings + assertEquals(1, result.offsetMapping.originalToTransformed(0)) + assertEquals(2, result.offsetMapping.originalToTransformed(1)) + assertEquals(0, result.offsetMapping.transformedToOriginal(1)) + assertEquals(1, result.offsetMapping.transformedToOriginal(2)) + } +}