diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt index 5f2e3a236d3..a71b5ee40ef 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/composition/LocalManagerProvider.kt @@ -22,8 +22,11 @@ import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeImporter import com.bitwarden.cxf.ui.composition.LocalCredentialExchangeRequestValidator import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator import com.bitwarden.cxf.validator.dsl.credentialExchangeRequestValidator +import com.bitwarden.ui.platform.composition.LocalBidiTextManager import com.bitwarden.ui.platform.composition.LocalIntentManager +import com.bitwarden.ui.platform.manager.BidiTextManager import com.bitwarden.ui.platform.manager.IntentManager +import com.bitwarden.ui.platform.manager.dsl.bidiTextManager import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManagerImpl @@ -79,6 +82,7 @@ fun LocalManagerProvider( }, credentialExchangeRequestValidator: CredentialExchangeRequestValidator = credentialExchangeRequestValidator(activity = activity), + bidiTextManager: BidiTextManager = bidiTextManager(), authTabLaunchers: AuthTabLaunchers, content: @Composable () -> Unit, ) { @@ -97,6 +101,7 @@ fun LocalManagerProvider( LocalCredentialExchangeImporter provides credentialExchangeImporter, LocalCredentialExchangeCompletionManager provides credentialExchangeCompletionManager, LocalCredentialExchangeRequestValidator provides credentialExchangeRequestValidator, + LocalBidiTextManager provides bidiTextManager, LocalAuthTabLaunchers provides authTabLaunchers, content = content, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/AccountSummaryListItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/AccountSummaryListItem.kt index d45f1639f78..1c408824a1d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/AccountSummaryListItem.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/exportitems/component/AccountSummaryListItem.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.bitwarden.ui.R +import com.bitwarden.ui.platform.composition.LocalBidiTextManager import com.bitwarden.ui.platform.base.util.cardStyle import com.bitwarden.ui.platform.base.util.hexToColor import com.bitwarden.ui.platform.base.util.toSafeOverlayColor @@ -51,6 +52,7 @@ fun AccountSummaryListItem( clickable: Boolean, onClick: (userId: String) -> Unit = {}, ) { + val bidiTextManager = LocalBidiTextManager.current Row( modifier = modifier .testTag("AccountSummaryListItem") @@ -95,7 +97,7 @@ fun AccountSummaryListItem( modifier = Modifier.weight(1f), ) { Text( - text = item.email, + text = bidiTextManager.unicodeWrap(item.email), style = BitwardenTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt index 565cd88e2ac..c30c2e74821 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt @@ -26,6 +26,7 @@ import com.bitwarden.ui.platform.components.field.BitwardenTextField import com.bitwarden.ui.platform.components.header.BitwardenListHeaderText import com.bitwarden.ui.platform.components.model.CardStyle import com.bitwarden.ui.platform.components.text.BitwardenHyperTextLink +import com.bitwarden.ui.platform.composition.LocalBidiTextManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme @@ -48,6 +49,7 @@ fun VaultItemCardContent( vaultCardItemTypeHandlers: VaultCardItemTypeHandlers, modifier: Modifier = Modifier, ) { + val bidiTextManager = LocalBidiTextManager.current var isExpanded by rememberSaveable { mutableStateOf(value = false) } val applyIconBackground = cardState.paymentCardBrandIconData == null LazyColumn(modifier = modifier.fillMaxWidth()) { @@ -72,7 +74,7 @@ fun VaultItemCardContent( item(key = "cardholderName") { BitwardenTextField( label = stringResource(id = BitwardenString.cardholder_name), - value = cardholderName, + value = bidiTextManager.unicodeWrap(cardholderName), onValueChange = {}, readOnly = true, singleLine = false, @@ -94,7 +96,7 @@ fun VaultItemCardContent( item(key = "cardNumber") { BitwardenPasswordField( label = stringResource(id = BitwardenString.number), - value = numberData.number, + value = bidiTextManager.formatCardNumber(numberData.number), onValueChange = {}, showPassword = numberData.isVisible, showPasswordChange = vaultCardItemTypeHandlers.onShowNumberClick, @@ -174,7 +176,7 @@ fun VaultItemCardContent( item(key = "securityCode") { BitwardenPasswordField( label = stringResource(id = BitwardenString.security_code), - value = securityCodeData.code, + value = bidiTextManager.forceLtr(securityCodeData.code), onValueChange = {}, showPassword = securityCodeData.isVisible, showPasswordChange = vaultCardItemTypeHandlers.onShowSecurityCodeClick, 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..df697a27a04 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 @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.bitwarden.ui.platform.base.util.standardHorizontalMargin +import com.bitwarden.ui.platform.composition.LocalBidiTextManager import com.bitwarden.ui.platform.base.util.toListItemCardStyle import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton import com.bitwarden.ui.platform.components.field.BitwardenTextField @@ -47,6 +48,7 @@ fun VaultItemIdentityContent( vaultIdentityItemTypeHandlers: VaultIdentityItemTypeHandlers, modifier: Modifier = Modifier, ) { + val bidiTextManager = LocalBidiTextManager.current var isExpanded by rememberSaveable { mutableStateOf(value = false) } LazyColumn( modifier = modifier.fillMaxWidth(), @@ -138,7 +140,7 @@ fun VaultItemIdentityContent( item(key = "ssn") { IdentityCopyField( label = stringResource(id = BitwardenString.ssn), - value = ssn, + value = bidiTextManager.forceLtr(ssn), copyContentDescription = stringResource(id = BitwardenString.copy_ssn), textFieldTestTag = "IdentitySsnEntry", copyActionTestTag = "IdentityCopySsnButton", @@ -160,7 +162,7 @@ fun VaultItemIdentityContent( item(key = "passportNumber") { IdentityCopyField( label = stringResource(id = BitwardenString.passport_number), - value = passportNumber, + value = bidiTextManager.unicodeWrap(passportNumber), copyContentDescription = stringResource(id = BitwardenString.copy_passport_number), textFieldTestTag = "IdentityPassportNumberEntry", copyActionTestTag = "IdentityCopyPassportNumberButton", @@ -182,7 +184,7 @@ fun VaultItemIdentityContent( item(key = "licenseNumber") { IdentityCopyField( label = stringResource(id = BitwardenString.license_number), - value = licenseNumber, + value = bidiTextManager.unicodeWrap(licenseNumber), copyContentDescription = stringResource(id = BitwardenString.copy_license_number), textFieldTestTag = "IdentityLicenseNumberEntry", copyActionTestTag = "IdentityCopyLicenseNumberButton", @@ -204,7 +206,7 @@ fun VaultItemIdentityContent( item(key = "email") { IdentityCopyField( label = stringResource(id = BitwardenString.email), - value = email, + value = bidiTextManager.unicodeWrap(email), copyContentDescription = stringResource(id = BitwardenString.copy_email), textFieldTestTag = "IdentityEmailEntry", copyActionTestTag = "IdentityCopyEmailButton", @@ -226,7 +228,7 @@ fun VaultItemIdentityContent( item(key = "phone") { IdentityCopyField( label = stringResource(id = BitwardenString.phone), - value = phone, + value = bidiTextManager.formatPhoneNumber(phone), copyContentDescription = stringResource(id = BitwardenString.copy_phone), textFieldTestTag = "IdentityPhoneEntry", copyActionTestTag = "IdentityCopyPhoneButton", @@ -248,7 +250,7 @@ fun VaultItemIdentityContent( item(key = "address") { IdentityCopyField( label = stringResource(id = BitwardenString.address), - value = address, + value = bidiTextManager.unicodeWrap(address), copyContentDescription = stringResource(id = BitwardenString.copy_address), textFieldTestTag = "IdentityAddressEntry", copyActionTestTag = "IdentityCopyAddressButton", 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..e5f77f35657 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,8 @@ 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.composition.LocalBidiTextManager +import com.bitwarden.ui.platform.manager.BidiTextManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme @@ -55,6 +57,7 @@ fun VaultItemLoginContent( vaultLoginItemTypeHandlers: VaultLoginItemTypeHandlers, modifier: Modifier = Modifier, ) { + val bidiTextManager = LocalBidiTextManager.current var isExpanded by rememberSaveable { mutableStateOf(value = false) } LazyColumn( modifier = modifier.fillMaxWidth(), @@ -174,6 +177,7 @@ fun VaultItemLoginContent( ) { index, uriData -> UriField( uriData = uriData, + bidiTextManager = bidiTextManager, onCopyUriClick = vaultLoginItemTypeHandlers.onCopyUriClick, onLaunchUriClick = vaultLoginItemTypeHandlers.onLaunchUriClick, cardStyle = uris.toListItemCardStyle(index = index, dividerPadding = 0.dp), @@ -391,10 +395,11 @@ private fun PasswordField( cardStyle: CardStyle, modifier: Modifier = Modifier, ) { + val bidiTextManager = LocalBidiTextManager.current if (passwordData.canViewPassword) { BitwardenPasswordField( label = stringResource(id = BitwardenString.password), - value = passwordData.password, + value = bidiTextManager.unicodeWrap(passwordData.password), showPasswordChange = { onShowPasswordClick(it) }, showPassword = passwordData.isVisible, onValueChange = { }, @@ -429,7 +434,7 @@ private fun PasswordField( } else { BitwardenHiddenPasswordField( label = stringResource(id = BitwardenString.password), - value = passwordData.password, + value = bidiTextManager.unicodeWrap(passwordData.password), passwordFieldTestTag = "LoginPasswordEntry", cardStyle = cardStyle, modifier = modifier, @@ -445,12 +450,15 @@ private fun TotpField( onAuthenticatorHelpToolTipClick: () -> Unit, modifier: Modifier = Modifier, ) { + val bidiTextManager = LocalBidiTextManager.current + if (enabled) { BitwardenTextField( label = stringResource(id = BitwardenString.authenticator_key), - value = totpCodeItemData.verificationCode - .chunked(AUTH_CODE_SPACING_INTERVAL) - .joinToString(" "), + value = bidiTextManager.formatVerificationCode( + totpCodeItemData.verificationCode, + chunkSize = AUTH_CODE_SPACING_INTERVAL, + ), onValueChange = { }, textStyle = BitwardenTheme.typography.sensitiveInfoSmall, readOnly = true, @@ -497,6 +505,7 @@ private fun TotpField( @Composable private fun UriField( uriData: VaultItemState.ViewState.Content.ItemType.Login.UriData, + bidiTextManager: BidiTextManager, onCopyUriClick: (String) -> Unit, onLaunchUriClick: (String) -> Unit, cardStyle: CardStyle, @@ -504,7 +513,7 @@ private fun UriField( ) { BitwardenTextField( label = stringResource(id = BitwardenString.website_uri), - value = uriData.uri, + value = bidiTextManager.unicodeWrap(uriData.uri), onValueChange = { }, readOnly = true, singleLine = false, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeItem.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeItem.kt index ba82c0c0cfd..1fc3103ac0f 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeItem.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeItem.kt @@ -21,6 +21,7 @@ import com.bitwarden.ui.platform.components.icon.BitwardenIcon import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.indicator.BitwardenCircularCountdownIndicator import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.composition.LocalBidiTextManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme @@ -55,6 +56,8 @@ fun VaultVerificationCodeItem( modifier: Modifier = Modifier, supportingLabel: String? = null, ) { + val bidiTextManager = LocalBidiTextManager.current + Row( modifier = modifier .defaultMinSize(minHeight = 60.dp) @@ -106,7 +109,7 @@ fun VaultVerificationCodeItem( if (!hideAuthCode) { Text( - text = authCode.chunked(3).joinToString(" "), + text = bidiTextManager.formatVerificationCode(authCode, chunkSize = 3), style = BitwardenTheme.typography.sensitiveInfoSmall, color = BitwardenTheme.colorScheme.text.primary, ) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt index 93837923157..2f57fd19444 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/base/BitwardenComposeTest.kt @@ -6,6 +6,7 @@ import com.bitwarden.cxf.manager.CredentialExchangeCompletionManager import com.bitwarden.cxf.validator.CredentialExchangeRequestValidator import com.bitwarden.ui.platform.base.BaseComposeTest import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme +import com.bitwarden.ui.platform.manager.BidiTextManager import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.theme.BitwardenTheme import com.x8bit.bitwarden.data.platform.manager.util.AppResumeStateManager @@ -48,6 +49,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() { credentialExchangeImporter: CredentialExchangeImporter = mockk(), credentialExchangeCompletionManager: CredentialExchangeCompletionManager = mockk(), credentialExchangeRequestValidator: CredentialExchangeRequestValidator = mockk(), + bidiTextManager: BidiTextManager = mockk(), test: @Composable () -> Unit, ) { setTestContent { @@ -67,6 +69,7 @@ abstract class BitwardenComposeTest : BaseComposeTest() { credentialExchangeImporter = credentialExchangeImporter, credentialExchangeCompletionManager = credentialExchangeCompletionManager, credentialExchangeRequestValidator = credentialExchangeRequestValidator, + bidiTextManager = bidiTextManager, ) { BitwardenTheme( theme = theme, 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..dc75bdf3984 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 @@ -29,6 +29,7 @@ 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.manager.IntentManager +import com.bitwarden.ui.platform.manager.dsl.bidiTextManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText @@ -71,6 +72,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { private var onNavigateToPasswordHistoryId: String? = null private val intentManager = mockk(relaxed = true) + private val bidiTextManager = bidiTextManager() private val mutableEventFlow = bufferedMutableSharedFlow() private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -83,6 +85,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { fun setUp() { setContent( intentManager = intentManager, + bidiTextManager = bidiTextManager, ) { VaultItemScreen( viewModel = viewModel, diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/VaultVerificationCodeItem.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/VaultVerificationCodeItem.kt index 4bd755ae2c7..3aafe3bceea 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/VaultVerificationCodeItem.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/components/listitem/VaultVerificationCodeItem.kt @@ -26,6 +26,7 @@ import com.bitwarden.ui.platform.components.icon.BitwardenIcon import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.indicator.BitwardenCircularCountdownIndicator import com.bitwarden.ui.platform.components.model.CardStyle +import com.bitwarden.ui.platform.composition.LocalBidiTextManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.platform.theme.BitwardenTheme @@ -98,6 +99,8 @@ fun VaultVerificationCodeItem( cardStyle: CardStyle, modifier: Modifier = Modifier, ) { + val bidiTextManager = LocalBidiTextManager.current + Row( modifier = modifier .testTag(tag = "Item") @@ -154,7 +157,7 @@ fun VaultVerificationCodeItem( Text( modifier = Modifier.testTag(tag = "AuthCode"), - text = authCode.chunked(size = 3).joinToString(separator = " "), + text = bidiTextManager.formatVerificationCode(authCode, chunkSize = 3), style = BitwardenTheme.typography.sensitiveInfoSmall, color = BitwardenTheme.colorScheme.text.primary, ) diff --git a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/composition/LocalManagerProvider.kt b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/composition/LocalManagerProvider.kt index 835b86acbff..58769f1b4f5 100644 --- a/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/composition/LocalManagerProvider.kt +++ b/authenticator/src/main/kotlin/com/bitwarden/authenticator/ui/platform/composition/LocalManagerProvider.kt @@ -18,8 +18,11 @@ import com.bitwarden.authenticator.ui.platform.manager.exit.ExitManagerImpl import com.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManager import com.bitwarden.authenticator.ui.platform.manager.permissions.PermissionsManagerImpl import com.bitwarden.core.data.manager.BuildInfoManager +import com.bitwarden.ui.platform.composition.LocalBidiTextManager import com.bitwarden.ui.platform.composition.LocalIntentManager +import com.bitwarden.ui.platform.manager.BidiTextManager import com.bitwarden.ui.platform.manager.IntentManager +import com.bitwarden.ui.platform.manager.dsl.bidiTextManager import java.time.Clock /** @@ -34,6 +37,7 @@ fun LocalManagerProvider( intentManager: IntentManager = IntentManager.create(activity, clock, buildInfoManager), exitManager: ExitManager = ExitManagerImpl(activity), biometricsManager: BiometricsManager = BiometricsManagerImpl(activity), + bidiTextManager: BidiTextManager = bidiTextManager(), content: @Composable () -> Unit, ) { CompositionLocalProvider( @@ -41,6 +45,7 @@ fun LocalManagerProvider( LocalIntentManager provides intentManager, LocalExitManager provides exitManager, LocalBiometricsManager provides biometricsManager, + LocalBidiTextManager provides bidiTextManager, content = content, ) } diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/composition/LocalBidiTextManagerProvider.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/composition/LocalBidiTextManagerProvider.kt new file mode 100644 index 00000000000..7cb6ec9716f --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/composition/LocalBidiTextManagerProvider.kt @@ -0,0 +1,15 @@ +@file:OmitFromCoverage + +package com.bitwarden.ui.platform.composition + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.ui.platform.manager.BidiTextManager + +/** + * CompositionLocal for [BidiTextManager]. + */ +val LocalBidiTextManager: ProvidableCompositionLocal = compositionLocalOf { + error("CompositionLocal BidiTextManager not present") +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/BidiTextManager.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/BidiTextManager.kt new file mode 100644 index 00000000000..93efe534da0 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/BidiTextManager.kt @@ -0,0 +1,82 @@ +package com.bitwarden.ui.platform.manager + +/** + * Manages bidirectional text handling, ensuring proper display of text containing both + * left-to-right (LTR) and right-to-left (RTL) characters. This is crucial for internationalization + * and supporting languages like Arabic, Hebrew, etc. + * + * This interface provides methods to wrap text with Unicode directionality control characters + * (`\u202A` for LTR and `\u202B` for RTL) to enforce a consistent base direction, preventing + * mixed-direction text from being garbled. + */ +interface BidiTextManager { + /** + * Wraps a [String] with unicode directionality characters to ensure it's displayed correctly + * regardless of the System's default layout direction. + * + * This is useful for displaying text that might contain mixed right-to-left (RTL) and + * left-to-right (LTR) content, preventing it from being incorrectly ordered or garbled. + * + * For example, an email address like "user@example.com" could be displayed incorrectly + * in an RTL context. This function wraps it to enforce LTR rendering. + * + * @param text The string to be wrapped. + * @return The wrapped string, ready for display in a BiDi-sensitive context. + */ + fun unicodeWrap(text: String): String + + /** + * Forces left-to-right (LTR) display direction for the given text by wrapping it with + * Unicode LTR embedding marks (U+202A...U+202C). + * + * Use this for content that should always display left-to-right regardless of system locale, + * such as URLs, code snippets, numeric codes, or technical identifiers. + * + * @param text The text to force as LTR. + * @return The text wrapped with LTR embedding marks. + */ + fun forceLtr(text: String): String + + /** + * Forces right-to-left (RTL) display direction for the given text by wrapping it with + * Unicode RTL embedding marks (U+202B...U+202C). + * + * Use this for content that should always display right-to-left, such as Arabic or Hebrew + * text in an otherwise LTR context. + * + * @param text The text to force as RTL. + * @return The text wrapped with RTL embedding marks. + */ + fun forceRtl(text: String): String + + /** + * Formats a verification code (such as TOTP) by grouping digits into chunks and ensuring + * left-to-right display direction. + * + * Example: "123456" with chunkSize=3 becomes "123 456" displayed as LTR. + * + * @param code The verification code to format. + * @param chunkSize The size of each chunk (default is 3). + * @return The formatted verification code with LTR directionality. + */ + fun formatVerificationCode(code: String, chunkSize: Int = 3): String + + /** + * Formats a phone number to ensure left-to-right display direction. + * + * Phone numbers should always display LTR regardless of system locale to maintain + * international formatting conventions. + * + * @param phone The phone number to format. + * @return The phone number with LTR directionality. + */ + fun formatPhoneNumber(phone: String): String + + /** + * Formats a credit/debit card number by ensuring left-to-right display direction. + * + * @param number The card number to format. + * @return The formatted card number with LTR directionality. + */ + fun formatCardNumber(number: String): String +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/BidiTextManagerImpl.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/BidiTextManagerImpl.kt new file mode 100644 index 00000000000..78e73414334 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/BidiTextManagerImpl.kt @@ -0,0 +1,51 @@ +package com.bitwarden.ui.platform.manager + +import androidx.core.text.BidiFormatter +import androidx.core.text.TextDirectionHeuristicsCompat +import com.bitwarden.annotation.OmitFromCoverage + +/** + * Default implementation of [BidiTextManager] using Android's [BidiFormatter] with + * appropriate [TextDirectionHeuristicsCompat] heuristics for bidirectional text handling. + */ +@OmitFromCoverage +internal class BidiTextManagerImpl : BidiTextManager { + + private val bidiFormatter: BidiFormatter = BidiFormatter.getInstance() + + override fun unicodeWrap(text: String): String { + return bidiFormatter.unicodeWrap( + text, + TextDirectionHeuristicsCompat.ANYRTL_LTR, + ) + } + + override fun forceLtr(text: String): String { + return bidiFormatter.unicodeWrap( + text, + TextDirectionHeuristicsCompat.LTR, + ) + } + + override fun forceRtl(text: String): String { + return bidiFormatter.unicodeWrap( + text, + TextDirectionHeuristicsCompat.RTL, + ) + } + + override fun formatVerificationCode(code: String, chunkSize: Int): String { + if (code.isEmpty()) return "" + + val chunks = code.chunked(chunkSize) + val formatted = chunks.joinToString(" ") + return forceLtr(formatted) + } + + override fun formatPhoneNumber(phone: String): String = forceLtr(phone) + + override fun formatCardNumber(number: String): String { + if (number.isEmpty()) return "" + return forceLtr(number) + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/dsl/BidiTextManagerBuilder.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/dsl/BidiTextManagerBuilder.kt new file mode 100644 index 00000000000..80b7d3e2697 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/manager/dsl/BidiTextManagerBuilder.kt @@ -0,0 +1,43 @@ +@file:OmitFromCoverage + +package com.bitwarden.ui.platform.manager.dsl + +import com.bitwarden.annotation.OmitFromCoverage +import com.bitwarden.ui.platform.manager.BidiTextManager +import com.bitwarden.ui.platform.manager.BidiTextManagerImpl + +/** + * A builder class for constructing an instance of [BidiTextManager]. + * + * This class follows the builder pattern and is designed to be used with the + * [bidiTextManager] DSL function. It allows for the configuration of necessary + * dependencies required by [BidiTextManager]. + * + * Example usage: + * ``` + * val bidiTextManager = bidiTextManager() + * ``` + * + * @see bidiTextManager + */ +@OmitFromCoverage +class BidiTextManagerBuilder internal constructor() { + internal fun build(): BidiTextManager = BidiTextManagerImpl() +} + +/** + * Creates an instance of [BidiTextManager] using the [BidiTextManagerBuilder] DSL. + * + * This function provides a convenient way to configure and build a [BidiTextManager]. + * + * @param builder A lambda with a receiver of type [BidiTextManagerBuilder] to configure + * the manager. + * + * @return A new instance of [BidiTextManager]. + * @see BidiTextManagerBuilder + */ +fun bidiTextManager( + builder: BidiTextManagerBuilder.() -> Unit = { }, +): BidiTextManager = BidiTextManagerBuilder() + .apply(builder) + .build() diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/manager/BidiTextManagerTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/manager/BidiTextManagerTest.kt new file mode 100644 index 00000000000..d3fdfe78792 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/manager/BidiTextManagerTest.kt @@ -0,0 +1,31 @@ +package com.bitwarden.ui.platform.manager + +import android.text.BidiFormatter +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * Unit tests for [BidiTextManagerImpl]. + * + * Note: [BidiTextManagerImpl] relies on Android's [BidiFormatter] which requires framework + * dependencies. These tests verify the basic logic (chunking, empty string handling) but + * full bidirectional text behavior should be tested via instrumentation tests on a real device + * or emulator where BidiFormatter is available. + */ +class BidiTextManagerTest { + private val manager = BidiTextManagerImpl() + + // Test chunking logic for verification codes + @Test + fun `formatVerificationCode handles empty string`() { + val result = manager.formatVerificationCode("") + assertEquals("", result) + } + + // Test chunking logic for card numbers + @Test + fun `formatCardNumber handles empty string`() { + val result = manager.formatCardNumber("") + assertEquals("", result) + } +}