Move bidi methods to BidiUtil.

This commit is contained in:
Greyson Parrelli
2025-02-27 13:58:22 -05:00
parent 791e95c645
commit e9e62b98f3
15 changed files with 201 additions and 186 deletions

View File

@@ -16,7 +16,7 @@ import androidx.appcompat.widget.AppCompatEditText
import androidx.core.animation.doOnEnd
import androidx.core.text.isDigitsOnly
import com.google.android.material.button.MaterialButton
import org.signal.core.util.StringUtil
import org.signal.core.util.BidiUtil
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
@@ -255,7 +255,7 @@ data class Boost(
dend: Int
): CharSequence? {
val result = dest.subSequence(0, dstart).toString() + source.toString() + dest.subSequence(dend, dest.length)
val resultWithoutCurrencyPrefix = StringUtil.stripBidiIndicator(result.removePrefix(symbol).removeSuffix(symbol).trim())
val resultWithoutCurrencyPrefix = BidiUtil.stripBidiIndicator(result.removePrefix(symbol).removeSuffix(symbol).trim())
if (resultWithoutCurrencyPrefix.length == 1 && !resultWithoutCurrencyPrefix.isDigitsOnly() && resultWithoutCurrencyPrefix != separator.toString()) {
return dest.subSequence(dstart, dend)

View File

@@ -11,7 +11,7 @@ import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.StringUtil
import org.signal.core.util.BidiUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.money.PlatformCurrencyUtil
@@ -158,7 +158,7 @@ class DonateToSignalViewModel(
}
fun setCustomAmount(rawAmount: String) {
val amount = StringUtil.stripBidiIndicator(rawAmount)
val amount = BidiUtil.stripBidiIndicator(rawAmount)
val bigDecimalAmount: BigDecimal = if (amount.isEmpty() || amount == DecimalFormatSymbols.getInstance().decimalSeparator.toString()) {
BigDecimal.ZERO
} else {

View File

@@ -70,6 +70,7 @@ import com.google.common.collect.Sets;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.BidiUtil;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
@@ -561,7 +562,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
isFooterVisible(messageRecord, nextMessageRecord, groupThread) &&
!bodyText.isJumbomoji() &&
conversationMessage.getBottomButton() == null &&
!StringUtil.hasMixedTextDirection(bodyText.getText()) &&
!BidiUtil.hasMixedTextDirection(bodyText.getText()) &&
!messageRecord.isRemoteDelete() &&
bodyText.getLastLineWidth() > 0)
{

View File

@@ -7,7 +7,7 @@ package org.thoughtcrime.securesms.conversation.v2.items
import android.view.View
import android.widget.Space
import org.signal.core.util.StringUtil
import org.signal.core.util.BidiUtil
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
@@ -80,7 +80,7 @@ class V2FooterPositionDelegate private constructor(
return false
}
if (body.isJumbomoji || StringUtil.hasMixedTextDirection(body.text)) {
if (body.isJumbomoji || BidiUtil.hasMixedTextDirection(body.text)) {
displayUnderneathBody()
return true
}

View File

@@ -6,7 +6,7 @@
package org.thoughtcrime.securesms.database.model
import okio.ByteString
import org.signal.core.util.StringUtil
import org.signal.core.util.BidiUtil
import org.signal.core.util.isNullOrEmpty
import org.signal.storageservice.protos.groups.AccessControl
import org.signal.storageservice.protos.groups.AccessControl.AccessRequired
@@ -340,7 +340,7 @@ object GroupsV2UpdateMessageConverter {
fun translateNewTitle(change: DecryptedGroupChange, editorUnknown: Boolean, updates: MutableList<GroupChangeChatUpdate.Update>) {
if (change.newTitle != null) {
val editorAci = if (editorUnknown) null else change.editorServiceIdBytes
val newTitle = StringUtil.isolateBidi(change.newTitle?.value_)
val newTitle = BidiUtil.isolateBidi(change.newTitle?.value_)
updates.add(
GroupChangeChatUpdate.Update(
groupNameUpdate = GroupNameUpdate(

View File

@@ -12,6 +12,7 @@ import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import org.signal.core.util.BidiUtil;
import org.signal.core.util.StringUtil;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.Member;
@@ -551,9 +552,9 @@ final class GroupsV2UpdateMessageProducer {
private void describeGroupNameUpdate(@NonNull GroupNameUpdate update, @NonNull List<UpdateDescription> updates) {
if (update.updaterAci == null) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, StringUtil.isolateBidi(update.newGroupName)), R.drawable.ic_update_group_name_16));
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, BidiUtil.isolateBidi(update.newGroupName)), R.drawable.ic_update_group_name_16));
} else {
String newTitle = StringUtil.isolateBidi(update.newGroupName);
String newTitle = BidiUtil.isolateBidi(update.newGroupName);
if (selfIds.matches(update.updaterAci)) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, newTitle), R.drawable.ic_update_group_name_16));
} else {
@@ -1081,7 +1082,7 @@ final class GroupsV2UpdateMessageProducer {
boolean editorIsYou = selfIds.matches(change.editorServiceIdBytes);
if (change.newTitle != null) {
String newTitle = StringUtil.isolateBidi(change.newTitle.value_);
String newTitle = BidiUtil.isolateBidi(change.newTitle.value_);
if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, newTitle), R.drawable.ic_update_group_name_16));
} else {
@@ -1104,7 +1105,7 @@ final class GroupsV2UpdateMessageProducer {
private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.newTitle != null) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, StringUtil.isolateBidi(change.newTitle.value_)), R.drawable.ic_update_group_name_16));
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, BidiUtil.isolateBidi(change.newTitle.value_)), R.drawable.ic_update_group_name_16));
}
}
@@ -1118,7 +1119,7 @@ final class GroupsV2UpdateMessageProducer {
updates.add(updateDescription(R.string.MessageRecord_s_changed_the_group_description, groupDescriptionUpdate.updaterAci, R.drawable.ic_update_group_name_16));
}
} else {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, StringUtil.isolateBidi(groupDescriptionUpdate.newDescription)), R.drawable.ic_update_group_name_16));
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, BidiUtil.isolateBidi(groupDescriptionUpdate.newDescription)), R.drawable.ic_update_group_name_16));
}
}

View File

@@ -34,6 +34,7 @@ import androidx.core.content.ContextCompat;
import com.annimon.stream.Stream;
import org.signal.core.util.Base64;
import org.signal.core.util.BidiUtil;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
@@ -470,8 +471,8 @@ public abstract class MessageRecord extends DisplayRecord {
if (profileChangeDetails != null) {
if (profileChangeDetails.profileNameChange != null) {
String displayName = getFromRecipient().getDisplayName(context);
String newName = StringUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.profileNameChange.newValue).toString());
String previousName = StringUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.profileNameChange.previous).toString());
String newName = BidiUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.profileNameChange.newValue).toString());
String previousName = BidiUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.profileNameChange.previous).toString());
String updateMessage;
if (getFromRecipient().isSystemContact()) {

View File

@@ -10,6 +10,7 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import com.google.i18n.phonenumbers.ShortNumberInfo;
import org.signal.core.util.BidiUtil;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.util.Pair;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -80,7 +81,7 @@ public class PhoneNumberFormatter {
}
public static @NonNull String prettyPrint(@NonNull String e164) {
return StringUtil.forceLtr(get(AppDependencies.getApplication()).prettyPrintFormat(e164));
return BidiUtil.forceLtr(get(AppDependencies.getApplication()).prettyPrintFormat(e164));
}
public @NonNull String prettyPrintFormat(@NonNull String e164) {
@@ -91,13 +92,13 @@ public class PhoneNumberFormatter {
localNumber.get().countryCode == parsedNumber.getCountryCode() &&
NATIONAL_FORMAT_COUNTRY_CODES.contains(localNumber.get().getCountryCode()))
{
return StringUtil.isolateBidi(phoneNumberUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL));
return BidiUtil.isolateBidi(phoneNumberUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.NATIONAL));
} else {
return StringUtil.isolateBidi(phoneNumberUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL));
return BidiUtil.isolateBidi(phoneNumberUtil.format(parsedNumber, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL));
}
} catch (NumberParseException e) {
Log.w(TAG, "Failed to format number: " + e.toString());
return StringUtil.isolateBidi(e164);
return BidiUtil.isolateBidi(e164);
}
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.signal.core.util.BidiUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.groups.GroupId;
@@ -155,9 +156,9 @@ class EditProfileViewModel extends ViewModel {
repository.uploadProfile(profileName,
displayName,
!Objects.equals(StringUtil.stripBidiProtection(oldDisplayName), displayName),
!Objects.equals(BidiUtil.stripBidiProtection(oldDisplayName), displayName),
description,
!Objects.equals(StringUtil.stripBidiProtection(oldDescription), description),
!Objects.equals(BidiUtil.stripBidiProtection(oldDescription), description),
newAvatar,
!Arrays.equals(oldAvatar, newAvatar),
uploadResult::postValue);

View File

@@ -7,7 +7,7 @@ import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.collections.immutable.toImmutableList
import org.signal.core.util.StringUtil
import org.signal.core.util.BidiUtil
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
@@ -532,7 +532,7 @@ class Recipient(
if (Util.isEmpty(name)) {
name = getUnknownDisplayName(context)
}
return StringUtil.isolateBidi(name)
return BidiUtil.isolateBidi(name)
}
fun hasNonUsernameDisplayName(context: Context): Boolean {
@@ -569,21 +569,21 @@ class Recipient(
/** A display name to use when rendering a mention of this user. */
fun getMentionDisplayName(context: Context): String {
var name: String? = if (isSelf) profileName.toString() else getGroupName(context)
name = StringUtil.isolateBidi(name)
name = BidiUtil.isolateBidi(name)
if (name.isBlank()) {
name = if (isSelf) getGroupName(context) else nickname.toString()
name = StringUtil.isolateBidi(name)
name = BidiUtil.isolateBidi(name)
}
if (name.isBlank()) {
name = if (isSelf) getGroupName(context) else systemContactName
name = StringUtil.isolateBidi(name)
name = BidiUtil.isolateBidi(name)
}
if (name.isBlank()) {
name = if (isSelf) getGroupName(context) else profileName.toString()
name = StringUtil.isolateBidi(name)
name = BidiUtil.isolateBidi(name)
}
if (name.isBlank() && e164Value.isNotNullOrBlank()) {
@@ -591,11 +591,11 @@ class Recipient(
}
if (name.isBlank()) {
name = StringUtil.isolateBidi(emailValue)
name = BidiUtil.isolateBidi(emailValue)
}
if (name.isBlank()) {
name = StringUtil.isolateBidi(context.getString(R.string.Recipient_unknown))
name = BidiUtil.isolateBidi(context.getString(R.string.Recipient_unknown))
}
return name
@@ -615,7 +615,7 @@ class Recipient(
getDisplayName(context)
).firstOrNull { it.isNotNullOrBlank() }
return StringUtil.isolateBidi(name)
return BidiUtil.isolateBidi(name)
}
private fun getUnknownDisplayName(context: Context): String {

View File

@@ -6,6 +6,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.BidiUtil;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.zkgroup.InvalidInputException;
@@ -115,7 +116,7 @@ public final class GroupUtil {
return description.toString();
}
String title = StringUtil.isolateBidi(groupContext.getName());
String title = BidiUtil.isolateBidi(groupContext.getName());
if (members != null && members.size() > 0) {
description.append("\n");

View File

@@ -14,7 +14,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.StringUtil
import org.signal.core.util.BidiUtil
import org.signal.storageservice.protos.groups.AccessControl
import org.signal.storageservice.protos.groups.AccessControl.AccessRequired
import org.signal.storageservice.protos.groups.AccessControl.AccessRequired.ADMINISTRATOR
@@ -683,7 +683,7 @@ class GroupsV2UpdateMessageProducerTest {
.title("New title")
.build()
assertEquals(listOf("Alice changed the group name to \"" + StringUtil.isolateBidi("New title") + "\"."), describeChange(change))
assertEquals(listOf("Alice changed the group name to \"" + BidiUtil.isolateBidi("New title") + "\"."), describeChange(change))
}
@Test
@@ -692,7 +692,7 @@ class GroupsV2UpdateMessageProducerTest {
.title("Title 2")
.build()
assertEquals(listOf("You changed the group name to \"" + StringUtil.isolateBidi("Title 2") + "\"."), describeChange(change))
assertEquals(listOf("You changed the group name to \"" + BidiUtil.isolateBidi("Title 2") + "\"."), describeChange(change))
}
@Test
@@ -701,7 +701,7 @@ class GroupsV2UpdateMessageProducerTest {
.title("Title 3")
.build()
assertEquals(listOf("The group name has changed to \"" + StringUtil.isolateBidi("Title 3") + "\"."), describeChange(change))
assertEquals(listOf("The group name has changed to \"" + BidiUtil.isolateBidi("Title 3") + "\"."), describeChange(change))
}
// Avatar change
@@ -1166,7 +1166,7 @@ class GroupsV2UpdateMessageProducerTest {
listOf(
"Alice added you to the group.",
"Alice added Bob.",
"Alice changed the group name to \"" + StringUtil.isolateBidi("Title") + "\".",
"Alice changed the group name to \"" + BidiUtil.isolateBidi("Title") + "\".",
"Alice set the disappearing message timer to 5 minutes.",
"Alice changed who can edit group membership to \"All members\"."
),
@@ -1219,7 +1219,7 @@ class GroupsV2UpdateMessageProducerTest {
assertEquals(
listOf(
"Bob joined the group.",
"The group name has changed to \"" + StringUtil.isolateBidi("Title 2") + "\".",
"The group name has changed to \"" + BidiUtil.isolateBidi("Title 2") + "\".",
"The group avatar has been changed.",
"The disappearing message timer has been set to 10 minutes.",
"Who can edit group membership has been changed to \"All members\"."
@@ -1241,7 +1241,7 @@ class GroupsV2UpdateMessageProducerTest {
listOf(
"Alice joined the group.",
"Alice is now an admin.",
"The group name has changed to \"" + StringUtil.isolateBidi("Updated title") + "\".",
"The group name has changed to \"" + BidiUtil.isolateBidi("Updated title") + "\".",
"Alice is no longer in the group."
),
describeChange(change)

View File

@@ -0,0 +1,155 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.core.util
import java.util.regex.Pattern
object BidiUtil {
private val ALL_ASCII_PATTERN: Pattern = Pattern.compile("^[\\x00-\\x7F]*$")
private object Bidi {
/** Override text direction */
val OVERRIDES: Set<Int> = SetUtil.newHashSet(
"\u202a".codePointAt(0), // LRE
"\u202b".codePointAt(0), // RLE
"\u202d".codePointAt(0), // LRO
"\u202e".codePointAt(0) // RLO
)
/** Set direction and isolate surrounding text */
val ISOLATES: Set<Int> = SetUtil.newHashSet(
"\u2066".codePointAt(0), // LRI
"\u2067".codePointAt(0), // RLI
"\u2068".codePointAt(0) // FSI
)
/** Closes things in [.OVERRIDES] */
val PDF: Int = "\u202c".codePointAt(0)
/** Closes things in [.ISOLATES] */
val PDI: Int = "\u2069".codePointAt(0)
/** Auto-detecting isolate */
val FSI: Int = "\u2068".codePointAt(0)
}
/**
* @return True if the provided text contains a mix of LTR and RTL characters, otherwise false.
*/
@JvmStatic
fun hasMixedTextDirection(text: CharSequence?): Boolean {
if (text == null) {
return false
}
var isLtr: Boolean? = null
var i = 0
val len = Character.codePointCount(text, 0, text.length)
while (i < len) {
val codePoint = Character.codePointAt(text, i)
val direction = Character.getDirectionality(codePoint)
val isLetter = Character.isLetter(codePoint)
if (isLtr != null && isLtr && direction != Character.DIRECTIONALITY_LEFT_TO_RIGHT && isLetter) {
return true
} else if (isLtr != null && !isLtr && direction != Character.DIRECTIONALITY_RIGHT_TO_LEFT && isLetter) {
return true
} else if (isLetter) {
isLtr = direction == Character.DIRECTIONALITY_LEFT_TO_RIGHT
}
i++
}
return false
}
/**
* Isolates bi-directional text from influencing surrounding text. You should use this whenever
* you're injecting user-generated text into a larger string.
*
* You'd think we'd be able to trust BidiFormatter, but unfortunately it just misses some
* corner cases, so here we are.
*
* The general idea is just to balance out the opening and closing codepoints, and then wrap the
* whole thing in FSI/PDI to isolate it.
*
* For more details, see:
* https://www.w3.org/International/questions/qa-bidi-unicode-controls
*/
@JvmStatic
fun isolateBidi(text: String?): String {
if (text == null) {
return ""
}
if (text.isEmpty()) {
return text
}
if (ALL_ASCII_PATTERN.matcher(text).matches()) {
return text
}
var overrideCount = 0
var overrideCloseCount = 0
var isolateCount = 0
var isolateCloseCount = 0
var i = 0
val len = text.codePointCount(0, text.length)
while (i < len) {
val codePoint = text.codePointAt(i)
if (Bidi.OVERRIDES.contains(codePoint)) {
overrideCount++
} else if (codePoint == Bidi.PDF) {
overrideCloseCount++
} else if (Bidi.ISOLATES.contains(codePoint)) {
isolateCount++
} else if (codePoint == Bidi.PDI) {
isolateCloseCount++
}
i++
}
val suffix = StringBuilder()
while (overrideCount > overrideCloseCount) {
suffix.appendCodePoint(Bidi.PDF)
overrideCloseCount++
}
while (isolateCount > isolateCloseCount) {
suffix.appendCodePoint(Bidi.FSI)
isolateCloseCount++
}
val out = StringBuilder()
return out.appendCodePoint(Bidi.FSI)
.append(text)
.append(suffix)
.appendCodePoint(Bidi.PDI)
.toString()
}
@JvmStatic
fun stripBidiProtection(text: String?): String? {
if (text == null) return null
return text.replace("[\\u2068\\u2069\\u202c]".toRegex(), "")
}
fun stripBidiIndicator(text: String): String {
return text.replace("\u200F", "")
}
@JvmStatic
fun forceLtr(text: CharSequence): String {
return "\u202a" + text + "\u202c"
}
}

View File

@@ -4,7 +4,6 @@ import android.text.SpannableStringBuilder
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.regex.Pattern
object StringUtil {
private val WHITESPACE: Set<Char> = setOf(
@@ -15,34 +14,6 @@ object StringUtil {
'\u2800' // braille blank
)
private val ALL_ASCII_PATTERN: Pattern = Pattern.compile("^[\\x00-\\x7F]*$")
private object Bidi {
/** Override text direction */
val OVERRIDES: Set<Int> = SetUtil.newHashSet(
"\u202a".codePointAt(0), /* LRE */
"\u202b".codePointAt(0), /* RLE */
"\u202d".codePointAt(0), /* LRO */
"\u202e".codePointAt(0) /* RLO */
)
/** Set direction and isolate surrounding text */
val ISOLATES: Set<Int> = SetUtil.newHashSet(
"\u2066".codePointAt(0), /* LRI */
"\u2067".codePointAt(0), /* RLI */
"\u2068".codePointAt(0) /* FSI */
)
/** Closes things in [.OVERRIDES] */
val PDF: Int = "\u202c".codePointAt(0)
/** Closes things in [.ISOLATES] */
val PDI: Int = "\u2069".codePointAt(0)
/** Auto-detecting isolate */
val FSI: Int = "\u2068".codePointAt(0)
}
/**
* Trims a name string to fit into the byte length requirement.
*
@@ -172,37 +143,6 @@ object StringUtil {
return String(Character.toChars(codePoint))
}
/**
* @return True if the provided text contains a mix of LTR and RTL characters, otherwise false.
*/
@JvmStatic
fun hasMixedTextDirection(text: CharSequence?): Boolean {
if (text == null) {
return false
}
var isLtr: Boolean? = null
var i = 0
val len = Character.codePointCount(text, 0, text.length)
while (i < len) {
val codePoint = Character.codePointAt(text, i)
val direction = Character.getDirectionality(codePoint)
val isLetter = Character.isLetter(codePoint)
if (isLtr != null && isLtr && direction != Character.DIRECTIONALITY_LEFT_TO_RIGHT && isLetter) {
return true
} else if (isLtr != null && !isLtr && direction != Character.DIRECTIONALITY_RIGHT_TO_LEFT && isLetter) {
return true
} else if (isLetter) {
isLtr = direction == Character.DIRECTIONALITY_LEFT_TO_RIGHT
}
i++
}
return false
}
/**
* @return True if the text is null or has a length of 0, otherwise false.
*/
@@ -211,87 +151,6 @@ object StringUtil {
return text.isNullOrEmpty()
}
/**
* Isolates bi-directional text from influencing surrounding text. You should use this whenever
* you're injecting user-generated text into a larger string.
*
* You'd think we'd be able to trust BidiFormatter, but unfortunately it just misses some
* corner cases, so here we are.
*
* The general idea is just to balance out the opening and closing codepoints, and then wrap the
* whole thing in FSI/PDI to isolate it.
*
* For more details, see:
* https://www.w3.org/International/questions/qa-bidi-unicode-controls
*/
@JvmStatic
fun isolateBidi(text: String?): String {
if (text == null) {
return ""
}
if (isEmpty(text)) {
return text
}
if (ALL_ASCII_PATTERN.matcher(text).matches()) {
return text
}
var overrideCount = 0
var overrideCloseCount = 0
var isolateCount = 0
var isolateCloseCount = 0
var i = 0
val len = text.codePointCount(0, text.length)
while (i < len) {
val codePoint = text.codePointAt(i)
if (Bidi.OVERRIDES.contains(codePoint)) {
overrideCount++
} else if (codePoint == Bidi.PDF) {
overrideCloseCount++
} else if (Bidi.ISOLATES.contains(codePoint)) {
isolateCount++
} else if (codePoint == Bidi.PDI) {
isolateCloseCount++
}
i++
}
val suffix = StringBuilder()
while (overrideCount > overrideCloseCount) {
suffix.appendCodePoint(Bidi.PDF)
overrideCloseCount++
}
while (isolateCount > isolateCloseCount) {
suffix.appendCodePoint(Bidi.FSI)
isolateCloseCount++
}
val out = StringBuilder()
return out.appendCodePoint(Bidi.FSI)
.append(text)
.append(suffix)
.appendCodePoint(Bidi.PDI)
.toString()
}
@JvmStatic
fun stripBidiProtection(text: String?): String? {
if (text == null) return null
return text.replace("[\\u2068\\u2069\\u202c]".toRegex(), "")
}
fun stripBidiIndicator(text: String): String {
return text.replace("\u200F", "")
}
/**
* Trims a [CharSequence] of starting and trailing whitespace. Behavior matches
* [String.trim] to preserve expectations around results.
@@ -341,11 +200,6 @@ object StringUtil {
return iterator.countBreaks()
}
@JvmStatic
fun forceLtr(text: CharSequence): String {
return "\u202a" + text + "\u202c"
}
@JvmStatic
fun replace(text: CharSequence, toReplace: Char, replacement: String?): CharSequence {
var updatedText: SpannableStringBuilder? = null

View File

@@ -49,7 +49,7 @@ public final class StringUtilTest_hasMixedTextDirection {
@Test
public void trim() {
boolean output = StringUtil.hasMixedTextDirection(input);
boolean output = BidiUtil.hasMixedTextDirection(input);
assertEquals(expected, output);
}
}