Improve handling of partially bi-directional text.

This commit is contained in:
Greyson Parrelli 2020-07-29 00:55:20 -04:00
parent e504ffa225
commit 8ed7fc894e
7 changed files with 108 additions and 24 deletions

View file

@ -18,6 +18,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemov
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.groups.GV2AccessLevelUtil; import org.thoughtcrime.securesms.groups.GV2AccessLevelUtil;
import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.StringUtil;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil; import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.util.UuidUtil;
@ -396,7 +397,7 @@ final class GroupsV2UpdateMessageProducer {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes); boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewTitle()) { if (change.hasNewTitle()) {
String newTitle = change.getNewTitle().getValue(); String newTitle = StringUtil.isolateBidi(change.getNewTitle().getValue());
if (editorIsYou) { if (editorIsYou) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, newTitle))); updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, newTitle)));
} else { } else {
@ -407,7 +408,7 @@ final class GroupsV2UpdateMessageProducer {
private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) { private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.hasNewTitle()) { if (change.hasNewTitle()) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, change.getNewTitle().getValue()))); updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, StringUtil.isolateBidi(change.getNewTitle().getValue()))));
} }
} }

View file

@ -39,6 +39,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.ExpirationUtil; import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.GroupUtil; import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.StringUtil;
import org.whispersystems.libsignal.util.guava.Function; import org.whispersystems.libsignal.util.guava.Function;
import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.util.UuidUtil;
@ -196,8 +197,8 @@ public abstract class MessageRecord extends DisplayRecord {
if (profileChangeDetails.hasProfileNameChange()) { if (profileChangeDetails.hasProfileNameChange()) {
String displayName = getIndividualRecipient().getDisplayName(context); String displayName = getIndividualRecipient().getDisplayName(context);
String newName = ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getNew()).toString(); String newName = StringUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getNew()).toString());
String previousName = ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getPrevious()).toString(); String previousName = StringUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getPrevious()).toString());
if (getIndividualRecipient().isSystemContact()) { if (getIndividualRecipient().isSystemContact()) {
return context.getString(R.string.MessageRecord_changed_their_profile_name_from_to, displayName, previousName, newName); return context.getString(R.string.MessageRecord_changed_their_profile_name_from_to, displayName, previousName, newName);

View file

@ -65,7 +65,7 @@ public class MessageRequestsBottomView extends ConstraintLayout {
if (recipient.isGroup()) { if (recipient.isGroup()) {
question.setText(R.string.MessageRequestBottomView_unblock_this_group_and_share_your_name_and_photo_with_its_members); question.setText(R.string.MessageRequestBottomView_unblock_this_group_and_share_your_name_and_photo_with_its_members);
} else { } else {
String name = recipient.getProfileName().isEmpty() ? recipient.getDisplayName(getContext()) : recipient.getProfileName().getGivenName(); String name = recipient.getShortDisplayName(getContext());
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them, HtmlUtil.bold(name)), 0)); question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them, HtmlUtil.bold(name)), 0));
} }
setActiveInactiveGroups(blockedButtons, normalButtons); setActiveInactiveGroups(blockedButtons, normalButtons);
@ -77,7 +77,7 @@ public class MessageRequestsBottomView extends ConstraintLayout {
question.setText(R.string.MessageRequestBottomView_do_you_want_to_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept); question.setText(R.string.MessageRequestBottomView_do_you_want_to_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept);
} }
} else { } else {
String name = recipient.getProfileName().isEmpty() ? recipient.getDisplayName(getContext()) : recipient.getProfileName().getGivenName(); String name = recipient.getShortDisplayName(getContext());
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, HtmlUtil.bold(name)), 0)); question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, HtmlUtil.bold(name)), 0));
} }
setActiveInactiveGroups(normalButtons, blockedButtons); setActiveInactiveGroups(normalButtons, blockedButtons);

View file

@ -26,19 +26,18 @@ import org.thoughtcrime.securesms.contacts.avatars.TransparentContactPhoto;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientIdResult;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil; import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.profiles.ProfileName; import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
@ -400,18 +399,22 @@ public class Recipient {
} }
public @NonNull String getDisplayName(@NonNull Context context) { public @NonNull String getDisplayName(@NonNull Context context) {
return Util.getFirstNonEmpty(getName(context), String name = Util.getFirstNonEmpty(getName(context),
getProfileName().toString(), getProfileName().toString(),
getDisplayUsername(), getDisplayUsername(),
e164, e164,
email, email,
context.getString(R.string.Recipient_unknown)); context.getString(R.string.Recipient_unknown));
return StringUtil.isolateBidi(name);
} }
public @NonNull String getShortDisplayName(@NonNull Context context) { public @NonNull String getShortDisplayName(@NonNull Context context) {
return Util.getFirstNonEmpty(getName(context), String name = Util.getFirstNonEmpty(getName(context),
getProfileName().getGivenName(), getProfileName().getGivenName(),
getDisplayName(context)); getDisplayName(context));
return StringUtil.isolateBidi(name);
} }
public @NonNull MaterialColor getColor() { public @NonNull MaterialColor getColor() {

View file

@ -125,7 +125,7 @@ public final class GroupUtil {
return description.toString(); return description.toString();
} }
String title = groupContext.getName(); String title = StringUtil.isolateBidi(groupContext.getName());
if (members != null && members.size() > 0) { if (members != null && members.size() > 0) {
description.append("\n"); description.append("\n");

View file

@ -2,10 +2,12 @@ package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.text.BidiFormatter;
import com.google.android.collect.Sets; import com.google.android.collect.Sets;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Set; import java.util.Set;
public final class StringUtil { public final class StringUtil {
@ -14,6 +16,27 @@ public final class StringUtil {
'\u200F', // right-to-left mark '\u200F', // right-to-left mark
'\u2007'); // figure space '\u2007'); // figure space
private static final class Bidi {
/** Override text direction */
private static final Set<Integer> OVERRIDES = Sets.newHashSet("\u202a".codePointAt(0), /* LRE */
"\u202b".codePointAt(0), /* RLE */
"\u202d".codePointAt(0), /* LRO */
"\u202e".codePointAt(0) /* RLO */);
/** Set direction and isolate surrounding text */
private static final Set<Integer> ISOLATES = Sets.newHashSet("\u2066".codePointAt(0), /* LRI */
"\u2067".codePointAt(0), /* RLI */
"\u2068".codePointAt(0) /* FSI */);
/** Closes things in {@link #OVERRIDES} */
private static final int PDF = "\u202c".codePointAt(0);
/** Closes things in {@link #ISOLATES} */
private static final int PDI = "\u2069".codePointAt(0);
/** Auto-detecting isolate */
private static final int FSI = "\u2068".codePointAt(0);
}
private StringUtil() { private StringUtil() {
} }
@ -99,4 +122,58 @@ public final class StringUtil {
public static @NonNull String codePointToString(int codePoint) { public static @NonNull String codePointToString(int codePoint) {
return new String(Character.toChars(codePoint)); return new String(Character.toChars(codePoint));
} }
/**
* 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 {@link 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
*/
public static @NonNull String isolateBidi(@NonNull String text) {
int overrideCount = 0;
int overrideCloseCount = 0;
int isolateCount = 0;
int isolateCloseCount = 0;
for (int i = 0, len = text.codePointCount(0, text.length()); i < len; i++) {
int 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++;
}
}
StringBuilder suffix = new StringBuilder();
while (overrideCount > overrideCloseCount) {
suffix.appendCodePoint(Bidi.PDF);
overrideCloseCount++;
}
while (isolateCount > isolateCloseCount) {
suffix.appendCodePoint(Bidi.FSI);
isolateCloseCount++;
}
StringBuilder out = new StringBuilder();
return out.appendCodePoint(Bidi.FSI)
.append(text)
.append(suffix)
.appendCodePoint(Bidi.PDI)
.toString();
}
} }

View file

@ -28,6 +28,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemov
import org.signal.storageservice.protos.groups.local.DecryptedString; import org.signal.storageservice.protos.groups.local.DecryptedString;
import org.signal.storageservice.protos.groups.local.DecryptedTimer; import org.signal.storageservice.protos.groups.local.DecryptedTimer;
import org.thoughtcrime.securesms.testutil.MainThreadUtil; import org.thoughtcrime.securesms.testutil.MainThreadUtil;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.util.UuidUtil; import org.whispersystems.signalservice.api.util.UuidUtil;
@ -43,6 +44,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.thoughtcrime.securesms.util.StringUtil.isolateBidi;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(manifest = Config.NONE, application = Application.class) @Config(manifest = Config.NONE, application = Application.class)
@ -603,7 +605,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.title("New title") .title("New title")
.build(); .build();
assertThat(describeChange(change), is(singletonList("Alice changed the group name to \"New title\"."))); assertThat(describeChange(change), is(singletonList("Alice changed the group name to \"" + isolateBidi("New title") + "\".")));
} }
@Test @Test
@ -612,7 +614,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.title("Title 2") .title("Title 2")
.build(); .build();
assertThat(describeChange(change), is(singletonList("You changed the group name to \"Title 2\"."))); assertThat(describeChange(change), is(singletonList("You changed the group name to \"" + isolateBidi("Title 2") + "\".")));
} }
@Test @Test
@ -621,7 +623,7 @@ public final class GroupsV2UpdateMessageProducerTest {
.title("Title 3") .title("Title 3")
.build(); .build();
assertThat(describeChange(change), is(singletonList("The group name has changed to \"Title 3\"."))); assertThat(describeChange(change), is(singletonList("The group name has changed to \"" + isolateBidi("Title 3") + "\".")));
} }
// Avatar change // Avatar change
@ -762,7 +764,7 @@ public final class GroupsV2UpdateMessageProducerTest {
assertThat(describeChange(change), is(Arrays.asList( assertThat(describeChange(change), is(Arrays.asList(
"Alice added Bob.", "Alice added Bob.",
"Alice changed the group name to \"Title\".", "Alice changed the group name to \"" + isolateBidi("Title") + "\".",
"Alice set the disappearing message timer to 5 minutes.", "Alice set the disappearing message timer to 5 minutes.",
"Alice changed who can edit group membership to \"All members\"."))); "Alice changed who can edit group membership to \"All members\".")));
} }
@ -803,7 +805,7 @@ public final class GroupsV2UpdateMessageProducerTest {
assertThat(describeChange(change), is(Arrays.asList( assertThat(describeChange(change), is(Arrays.asList(
"Bob joined the group.", "Bob joined the group.",
"The group name has changed to \"Title 2\".", "The group name has changed to \"" + isolateBidi("Title 2") + "\".",
"The group avatar has been changed.", "The group avatar has been changed.",
"The disappearing message timer has been set to 10 minutes.", "The disappearing message timer has been set to 10 minutes.",
"Who can edit group membership has been changed to \"All members\"."))); "Who can edit group membership has been changed to \"All members\".")));
@ -821,7 +823,7 @@ public final class GroupsV2UpdateMessageProducerTest {
assertThat(describeChange(change), is(Arrays.asList( assertThat(describeChange(change), is(Arrays.asList(
"Alice joined the group.", "Alice joined the group.",
"Alice is now an admin.", "Alice is now an admin.",
"The group name has changed to \"Updated title\".", "The group name has changed to \"" + isolateBidi("Updated title") + "\".",
"Alice is no longer in the group."))); "Alice is no longer in the group.")));
} }