Skip to content

Commit 417726a

Browse files
committed
Force LTR direction for password and sensitive fields
This commit introduces a `VisualTransformation` to enforce Left-To-Right (LTR) text direction in password fields and other sensitive inputs. This ensures consistent layout and prevents rendering issues, particularly in Right-To-Left (RTL) locales where characters like asterisks might otherwise appear incorrectly aligned. A `CompoundVisualTransformation` utility has also been added to allow chaining multiple `VisualTransformation`s together. Specific changes: - Created `ForceLtrVisualTransformation` to wrap text with LTR Unicode control characters. - Created `CompoundVisualTransformation` to combine multiple transformations, preserving correct offset mapping. - Applied `forceLtrVisualTransformation` to `BitwardenPasswordField` and `BitwardenHiddenPasswordField`, combining it with the existing `PasswordVisualTransformation` or `nonLetterColorVisualTransformation`. - Enforced LTR direction on generated passwords in the Password History screen. - Applied LTR transformation to sensitive fields in the "Identity" and "Login" item views, such as TOTP, URI, SSN, and passport number.
1 parent c16d31f commit 417726a

File tree

7 files changed

+151
-8
lines changed

7 files changed

+151
-8
lines changed

app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/generator/passwordhistory/PasswordHistoryListItem.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import com.bitwarden.ui.platform.base.util.withLineBreaksAtWidth
2222
import com.bitwarden.ui.platform.base.util.withVisualTransformation
2323
import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton
2424
import com.bitwarden.ui.platform.components.model.CardStyle
25+
import com.bitwarden.ui.platform.components.util.compoundVisualTransformation
26+
import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation
2527
import com.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
2628
import com.bitwarden.ui.platform.resource.BitwardenDrawable
2729
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -61,7 +63,10 @@ fun PasswordHistoryListItem(
6163
)
6264
Text(
6365
text = formattedText.withVisualTransformation(
64-
visualTransformation = nonLetterColorVisualTransformation(),
66+
visualTransformation = compoundVisualTransformation(
67+
forceLtrVisualTransformation(),
68+
nonLetterColorVisualTransformation(),
69+
),
6570
),
6671
style = textStyle,
6772
color = BitwardenTheme.colorScheme.text.primary,

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import androidx.compose.runtime.setValue
1818
import androidx.compose.ui.Modifier
1919
import androidx.compose.ui.platform.testTag
2020
import androidx.compose.ui.res.stringResource
21+
import androidx.compose.ui.text.input.VisualTransformation
2122
import androidx.compose.ui.unit.dp
2223
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
2324
import com.bitwarden.ui.platform.base.util.toListItemCardStyle
@@ -27,6 +28,7 @@ import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText
2728
import com.bitwarden.ui.platform.components.icon.model.IconData
2829
import com.bitwarden.ui.platform.components.model.CardStyle
2930
import com.bitwarden.ui.platform.components.text.BitwardenHyperTextLink
31+
import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation
3032
import com.bitwarden.ui.platform.resource.BitwardenDrawable
3133
import com.bitwarden.ui.platform.resource.BitwardenString
3234
import com.bitwarden.ui.platform.theme.BitwardenTheme
@@ -149,6 +151,7 @@ fun VaultItemIdentityContent(
149151
index = identityState.propertyList.indexOf(element = ssn),
150152
dividerPadding = 0.dp,
151153
),
154+
forceLtr = true,
152155
modifier = Modifier
153156
.fillMaxWidth()
154157
.standardHorizontalMargin()
@@ -171,6 +174,7 @@ fun VaultItemIdentityContent(
171174
index = identityState.propertyList.indexOf(element = passportNumber),
172175
dividerPadding = 0.dp,
173176
),
177+
forceLtr = true,
174178
modifier = Modifier
175179
.fillMaxWidth()
176180
.standardHorizontalMargin()
@@ -193,6 +197,7 @@ fun VaultItemIdentityContent(
193197
index = identityState.propertyList.indexOf(element = licenseNumber),
194198
dividerPadding = 0.dp,
195199
),
200+
forceLtr = true,
196201
modifier = Modifier
197202
.fillMaxWidth()
198203
.standardHorizontalMargin()
@@ -215,6 +220,7 @@ fun VaultItemIdentityContent(
215220
index = identityState.propertyList.indexOf(element = email),
216221
dividerPadding = 0.dp,
217222
),
223+
forceLtr = true,
218224
modifier = Modifier
219225
.fillMaxWidth()
220226
.standardHorizontalMargin()
@@ -237,6 +243,7 @@ fun VaultItemIdentityContent(
237243
index = identityState.propertyList.indexOf(element = phone),
238244
dividerPadding = 0.dp,
239245
),
246+
forceLtr = true,
240247
modifier = Modifier
241248
.fillMaxWidth()
242249
.standardHorizontalMargin()
@@ -422,6 +429,7 @@ private fun IdentityCopyField(
422429
onCopyClick: () -> Unit,
423430
cardStyle: CardStyle,
424431
modifier: Modifier = Modifier,
432+
forceLtr: Boolean = false,
425433
) {
426434
BitwardenTextField(
427435
label = label,
@@ -439,6 +447,11 @@ private fun IdentityCopyField(
439447
},
440448
textFieldTestTag = textFieldTestTag,
441449
cardStyle = cardStyle,
450+
visualTransformation = if (forceLtr) {
451+
forceLtrVisualTransformation()
452+
} else {
453+
VisualTransformation.None
454+
},
442455
modifier = modifier,
443456
)
444457
}

app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import com.bitwarden.ui.platform.components.model.CardStyle
3232
import com.bitwarden.ui.platform.components.model.TooltipData
3333
import com.bitwarden.ui.platform.components.text.BitwardenClickableText
3434
import com.bitwarden.ui.platform.components.text.BitwardenHyperTextLink
35+
import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation
3536
import com.bitwarden.ui.platform.resource.BitwardenDrawable
3637
import com.bitwarden.ui.platform.resource.BitwardenString
3738
import com.bitwarden.ui.platform.theme.BitwardenTheme
@@ -473,6 +474,7 @@ private fun TotpField(
473474
},
474475
textFieldTestTag = "LoginTotpEntry",
475476
cardStyle = CardStyle.Full,
477+
visualTransformation = forceLtrVisualTransformation(),
476478
modifier = modifier,
477479
)
478480
} else {
@@ -528,6 +530,7 @@ private fun UriField(
528530
},
529531
textFieldTestTag = "LoginUriEntry",
530532
cardStyle = cardStyle,
533+
visualTransformation = forceLtrVisualTransformation(),
531534
modifier = modifier,
532535
)
533536
}

ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenHiddenPasswordField.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import com.bitwarden.ui.platform.base.util.nullableTestTag
1616
import com.bitwarden.ui.platform.components.field.color.bitwardenTextFieldColors
1717
import com.bitwarden.ui.platform.components.field.toolbar.BitwardenEmptyTextToolbar
1818
import com.bitwarden.ui.platform.components.model.CardStyle
19+
import com.bitwarden.ui.platform.components.util.compoundVisualTransformation
20+
import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation
1921
import com.bitwarden.ui.platform.theme.BitwardenTheme
2022

2123
/**
@@ -44,7 +46,10 @@ fun BitwardenHiddenPasswordField(
4446
label = label?.let { { Text(text = it) } },
4547
value = value,
4648
onValueChange = { },
47-
visualTransformation = PasswordVisualTransformation(),
49+
visualTransformation = compoundVisualTransformation(
50+
PasswordVisualTransformation(),
51+
forceLtrVisualTransformation(),
52+
),
4853
singleLine = true,
4954
enabled = false,
5055
readOnly = true,

ui/src/main/kotlin/com/bitwarden/ui/platform/components/field/BitwardenPasswordField.kt

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ import androidx.compose.ui.text.input.ImeAction
4040
import androidx.compose.ui.text.input.KeyboardType
4141
import androidx.compose.ui.text.input.PasswordVisualTransformation
4242
import androidx.compose.ui.text.input.TextFieldValue
43-
import androidx.compose.ui.text.input.VisualTransformation
4443
import androidx.compose.ui.tooling.preview.Preview
4544
import androidx.compose.ui.unit.dp
4645
import com.bitwarden.ui.platform.base.util.cardStyle
@@ -56,6 +55,8 @@ import com.bitwarden.ui.platform.components.model.CardStyle
5655
import com.bitwarden.ui.platform.components.model.TooltipData
5756
import com.bitwarden.ui.platform.components.row.BitwardenRowOfActions
5857
import com.bitwarden.ui.platform.components.support.BitwardenSupportingContent
58+
import com.bitwarden.ui.platform.components.util.compoundVisualTransformation
59+
import com.bitwarden.ui.platform.components.util.forceLtrVisualTransformation
5960
import com.bitwarden.ui.platform.components.util.nonLetterColorVisualTransformation
6061
import com.bitwarden.ui.platform.resource.BitwardenDrawable
6162
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -140,6 +141,21 @@ fun BitwardenPasswordField(
140141
TextToolbarType.NONE -> BitwardenEmptyTextToolbar
141142
}
142143
var lastTextValue by remember(value) { mutableStateOf(value = value) }
144+
145+
val visualTransformation = when {
146+
!showPassword -> compoundVisualTransformation(
147+
PasswordVisualTransformation(),
148+
forceLtrVisualTransformation(),
149+
)
150+
151+
readOnly -> compoundVisualTransformation(
152+
nonLetterColorVisualTransformation(),
153+
forceLtrVisualTransformation(),
154+
)
155+
156+
else -> forceLtrVisualTransformation()
157+
}
158+
143159
CompositionLocalProvider(value = LocalTextToolbar provides textToolbar) {
144160
Column(
145161
modifier = modifier
@@ -191,11 +207,7 @@ fun BitwardenPasswordField(
191207
onValueChange(it.text)
192208
}
193209
},
194-
visualTransformation = when {
195-
!showPassword -> PasswordVisualTransformation()
196-
readOnly -> nonLetterColorVisualTransformation()
197-
else -> VisualTransformation.None
198-
},
210+
visualTransformation = visualTransformation,
199211
singleLine = singleLine,
200212
readOnly = readOnly,
201213
keyboardOptions = KeyboardOptions(
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.bitwarden.ui.platform.components.util
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.remember
5+
import androidx.compose.ui.text.AnnotatedString
6+
import androidx.compose.ui.text.input.OffsetMapping
7+
import androidx.compose.ui.text.input.TransformedText
8+
import androidx.compose.ui.text.input.VisualTransformation
9+
10+
/**
11+
* A [VisualTransformation] that chains multiple other [VisualTransformation]s.
12+
*
13+
* This is useful for applying multiple transformations to a text field. The transformations
14+
* are applied in the order they are provided.
15+
*/
16+
private class CompoundVisualTransformation(
17+
vararg val transformations: VisualTransformation,
18+
) : VisualTransformation {
19+
override fun filter(text: AnnotatedString): TransformedText {
20+
return transformations.fold(
21+
TransformedText(
22+
text,
23+
OffsetMapping.Identity,
24+
),
25+
) { acc, transformation ->
26+
val result = transformation.filter(acc.text)
27+
28+
val composedMapping = object : OffsetMapping {
29+
override fun originalToTransformed(offset: Int): Int {
30+
val originalTransformed = acc.offsetMapping.originalToTransformed(offset)
31+
return result.offsetMapping.originalToTransformed(originalTransformed)
32+
}
33+
34+
override fun transformedToOriginal(offset: Int): Int {
35+
val resultOriginal = result.offsetMapping.transformedToOriginal(offset)
36+
return acc.offsetMapping.transformedToOriginal(resultOriginal)
37+
}
38+
}
39+
TransformedText(result.text, composedMapping)
40+
}
41+
}
42+
}
43+
44+
/**
45+
* Remembers a [CompoundVisualTransformation] for the given [transformations].
46+
*
47+
* This is an optimization to avoid creating a new [CompoundVisualTransformation] on every
48+
* recomposition.
49+
*/
50+
@Composable
51+
fun compoundVisualTransformation(
52+
vararg transformations: VisualTransformation,
53+
): VisualTransformation =
54+
remember(*transformations) {
55+
CompoundVisualTransformation(*transformations)
56+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.bitwarden.ui.platform.components.util
2+
3+
import androidx.compose.runtime.Composable
4+
import androidx.compose.runtime.remember
5+
import androidx.compose.ui.text.AnnotatedString
6+
import androidx.compose.ui.text.buildAnnotatedString
7+
import androidx.compose.ui.text.input.OffsetMapping
8+
import androidx.compose.ui.text.input.TransformedText
9+
import androidx.compose.ui.text.input.VisualTransformation
10+
11+
// Unicode characters for forcing LTR direction
12+
private const val LRO = "\u202A"
13+
private const val PDF = "\u202C"
14+
15+
/**
16+
* A [VisualTransformation] that forces the output to have an LTR text direction.
17+
*
18+
* This is useful for password fields where the input should always be LTR, even when the rest of
19+
* the UI is RTL.
20+
*/
21+
private object ForceLtrVisualTransformation : VisualTransformation {
22+
override fun filter(text: AnnotatedString): TransformedText {
23+
val forcedLtrText = buildAnnotatedString {
24+
append(LRO)
25+
append(text)
26+
append(PDF)
27+
}
28+
29+
val offsetMapping = object : OffsetMapping {
30+
override fun originalToTransformed(offset: Int): Int = offset + 1
31+
32+
override fun transformedToOriginal(offset: Int): Int =
33+
(offset - 1).coerceIn(0, text.length)
34+
}
35+
36+
return TransformedText(forcedLtrText, offsetMapping)
37+
}
38+
}
39+
40+
/**
41+
* Remembers a [ForceLtrVisualTransformation] for the given [transformations].
42+
*
43+
* This is an optimization to avoid creating a new [ForceLtrVisualTransformation] on every
44+
* recomposition.
45+
*/
46+
@Composable
47+
fun forceLtrVisualTransformation(): VisualTransformation = remember {
48+
ForceLtrVisualTransformation
49+
}

0 commit comments

Comments
 (0)