diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt index dd1dedc2ff..33f093b82d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/boost/Boost.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt index a0dc0e5023..627690b6dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalViewModel.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 5c68b414ac..ce4511951a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt index 0baf4c37ef..8a456665d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/items/V2FooterPositionDelegate.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt index 6cb9b1c4d1..0ddbbc4ff4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageConverter.kt @@ -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) { 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( diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java index 7a52429712..bb1d75000d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducer.java @@ -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 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 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)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 53cfa2c86e..497f5b97dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -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()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java index 6fe1d8f4fb..c8bce00089 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/phonenumbers/PhoneNumberFormatter.java @@ -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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java index bbe1599603..7738c78b4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/edit/EditProfileViewModel.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt index 8d7443f3a1..b038b3bdb4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java index 3011f8956a..f76f85eab5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/GroupUtil.java @@ -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"); diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.kt index 0893188785..8f08fc6985 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/model/GroupsV2UpdateMessageProducerTest.kt @@ -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) diff --git a/core-util-jvm/src/main/java/org/signal/core/util/BidiUtil.kt b/core-util-jvm/src/main/java/org/signal/core/util/BidiUtil.kt new file mode 100644 index 0000000000..6dd7924cda --- /dev/null +++ b/core-util-jvm/src/main/java/org/signal/core/util/BidiUtil.kt @@ -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 = 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 = 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" + } +} diff --git a/core-util/src/main/java/org/signal/core/util/StringUtil.kt b/core-util/src/main/java/org/signal/core/util/StringUtil.kt index 143a9a77f2..7bdc21c59c 100644 --- a/core-util/src/main/java/org/signal/core/util/StringUtil.kt +++ b/core-util/src/main/java/org/signal/core/util/StringUtil.kt @@ -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 = 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 = 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 = 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 diff --git a/core-util/src/test/java/org/signal/core/util/StringUtilTest_hasMixedTextDirection.java b/core-util/src/test/java/org/signal/core/util/StringUtilTest_hasMixedTextDirection.java index 971496497f..caa74b702f 100644 --- a/core-util/src/test/java/org/signal/core/util/StringUtilTest_hasMixedTextDirection.java +++ b/core-util/src/test/java/org/signal/core/util/StringUtilTest_hasMixedTextDirection.java @@ -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); } }