diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java index ec8e7d2edf..1bb31bfff2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiUtil.java @@ -16,6 +16,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Pattern; public final class EmojiUtil { @@ -42,6 +43,8 @@ public final class EmojiUtil { MAX_EMOJI_LENGTH = max; } + private static final Pattern EMOJI_PATTERN = Pattern.compile("^(?:(?:[\u00a9\u00ae\u203c\u2049\u2122\u2139\u2194-\u2199\u21a9-\u21aa\u231a-\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa\u24c2\u25aa-\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614-\u2615\u2618\u261d\u2620\u2622-\u2623\u2626\u262a\u262e-\u262f\u2638-\u263a\u2648-\u2653\u2660\u2663\u2665-\u2666\u2668\u267b\u267f\u2692-\u2694\u2696-\u2697\u2699\u269b-\u269c\u26a0-\u26a1\u26aa-\u26ab\u26b0-\u26b1\u26bd-\u26be\u26c4-\u26c5\u26c8\u26ce-\u26cf\u26d1\u26d3-\u26d4\u26e9-\u26ea\u26f0-\u26f5\u26f7-\u26fa\u26fd\u2702\u2705\u2708-\u270d\u270f\u2712\u2714\u2716\u271d\u2721\u2728\u2733-\u2734\u2744\u2747\u274c\u274e\u2753-\u2755\u2757\u2763-\u2764\u2795-\u2797\u27a1\u27b0\u27bf\u2934-\u2935\u2b05-\u2b07\u2b1b-\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299\ud83c\udc04\ud83c\udccf\ud83c\udd70-\ud83c\udd71\ud83c\udd7e-\ud83c\udd7f\ud83c\udd8e\ud83c\udd91-\ud83c\udd9a\ud83c\ude01-\ud83c\ude02\ud83c\ude1a\ud83c\ude2f\ud83c\ude32-\ud83c\ude3a\ud83c\ude50-\ud83c\ude51\u200d\ud83c\udf00-\ud83d\uddff\ud83d\ude00-\ud83d\ude4f\ud83d\ude80-\ud83d\udeff\ud83e\udd00-\ud83e\uddff\udb40\udc20-\udb40\udc7f]|\u200d[\u2640\u2642]|[\ud83c\udde6-\ud83c\uddff]{2}|.[\u20e0\u20e3\ufe0f]+)+)+$"); + private EmojiUtil() {} public static List getDisplayPages() { @@ -90,4 +93,25 @@ public final class EmojiUtil { return EmojiProvider.getInstance(context).getEmojiDrawable(emoji); } } + + /** + * True if the text is likely a single, valid emoji. Otherwise false. + * + * We do a two-tier check: first using our own knowledge of emojis (which could be incomplete), + * followed by a more wide check for all of the valid emoji unicode ranges (which could lead to + * some false positives). YMMV. + */ + public static boolean isEmoji(@NonNull Context context, @Nullable String text) { + if (Util.isEmpty(text)) { + return false; + } + + if (StringUtil.getGraphemeCount(text) != 1) { + return false; + } + + EmojiParser.CandidateList candidates = EmojiProvider.getInstance(context).getCandidates(text); + + return (candidates != null && candidates.size() > 0) || EMOJI_PATTERN.matcher(text).matches(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index bb14e2bd01..4b883a1021 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -20,6 +20,8 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.attachments.PointerAttachment; import org.thoughtcrime.securesms.attachments.TombstoneAttachment; import org.thoughtcrime.securesms.attachments.UriAttachment; +import org.thoughtcrime.securesms.components.emoji.Emoji; +import org.thoughtcrime.securesms.components.emoji.EmojiUtil; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactModelMapper; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; @@ -690,6 +692,11 @@ public final class MessageContentProcessor { private void handleReaction(@NonNull SignalServiceContent content, @NonNull SignalServiceDataMessage message) { SignalServiceDataMessage.Reaction reaction = message.getReaction().get(); + if (!EmojiUtil.isEmoji(context, reaction.getEmoji())) { + Log.w(TAG, "Reaction text is not a valid emoji! Ignoring the message."); + return; + } + Recipient targetAuthor = Recipient.externalPush(context, reaction.getTargetAuthor()); MessageRecord targetMessage = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(reaction.getTargetSentTimestamp(), targetAuthor.getId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java index faa3ffeea6..4ff4407367 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java @@ -6,6 +6,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.text.BidiFormatter; +import org.signal.core.util.BreakIteratorCompat; + import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -265,4 +267,13 @@ public final class StringUtil { int end = (maxChars - 1) - start; return text.subSequence(0, start) + "…" + text.subSequence(text.length() - end, text.length()); } + + /** + * @return The number of graphemes in the provided string. + */ + public static int getGraphemeCount(@NonNull CharSequence text) { + BreakIteratorCompat iterator = BreakIteratorCompat.getInstance(); + iterator.setText(text); + return iterator.countBreaks(); + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/components/emoji/EmojiUtilTest_isEmoji.java b/app/src/test/java/org/thoughtcrime/securesms/components/emoji/EmojiUtilTest_isEmoji.java new file mode 100644 index 0000000000..ab346e0992 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/components/emoji/EmojiUtilTest_isEmoji.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.components.emoji; + +import android.app.Application; +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Arrays; +import java.util.Collection; + +import static org.junit.Assert.assertEquals; + +@RunWith(ParameterizedRobolectricTestRunner.class) +@Config(manifest = Config.NONE, application = Application.class) +public class EmojiUtilTest_isEmoji { + + private final String input; + private final boolean output; + + @ParameterizedRobolectricTestRunner.Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + { null, false }, + { "", false }, + { "cat", false }, + { "ᑢᗩᖶ", false }, + { "♍︎♋︎⧫︎", false }, + { "ᑢ", false }, + { "¯\\_(ツ)_/¯", false}, + { "\uD83D\uDE0D", true }, // Smiling face with heart-shaped eyes + { "\uD83D\uDD77", true }, // Spider + { "\uD83E\uDD37", true }, // Person shrugging + { "\uD83E\uDD37\uD83C\uDFFF\u200D♂️", true }, // Man shrugging dark skin tone + { "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66", true }, // Family: Man, Woman, Girl, Boy + { "\uD83D\uDC68\uD83C\uDFFB\u200D\uD83D\uDC69\uD83C\uDFFB\u200D\uD83D\uDC67\uD83C\uDFFB\u200D\uD83D\uDC66\uD83C\uDFFB", true }, // Family - Man: Light Skin Tone, Woman: Light Skin Tone, Girl: Light Skin Tone, Boy: Light Skin Tone (NOTE: Not widely supported, good stretch test) + { "\uD83D\uDE0Dhi", false }, // Smiling face with heart-shaped eyes, text afterwards + { "\uD83D\uDE0D ", false }, // Smiling face with heart-shaped eyes, space afterwards + { "\uD83D\uDE0D\uD83D\uDE0D", false }, // Smiling face with heart-shaped eyes, twice + }); + } + + + public EmojiUtilTest_isEmoji(String input, boolean output) { + this.input = input; + this.output = output; + } + + @Test + public void isEmoji() { + Context context = ApplicationProvider.getApplicationContext(); + + assertEquals(output, EmojiUtil.isEmoji(context, input)); + } +}