From c9a59a7417655565bd7002655866c602b53be605 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 9 Oct 2025 12:02:16 -0300 Subject: [PATCH] Fix ANR when trying to load a message composed of 10,000 random emoji. --- .../components/emoji/EmojiTextView.java | 159 +++++++++++++++--- .../conversation/ConversationItem.java | 2 +- .../longmessage/LongMessageFragment.java | 3 +- 3 files changed, 134 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index 46c1eee187..b6df3032d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -8,6 +8,7 @@ import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Annotation; import android.text.Layout; +import android.text.PrecomputedText; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; @@ -29,10 +30,12 @@ import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.appcompat.widget.AppCompatTextView; import androidx.core.content.ContextCompat; +import androidx.core.text.PrecomputedTextCompat; import androidx.core.view.GestureDetectorCompat; import androidx.core.view.ViewKt; import androidx.core.widget.TextViewCompat; +import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; @@ -42,10 +45,14 @@ import org.thoughtcrime.securesms.conversation.MessageStyler; import org.thoughtcrime.securesms.emoji.JumboEmoji; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor; +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.concurrent.Executor; import kotlin.Unit; @@ -85,6 +92,12 @@ public class EmojiTextView extends AppCompatTextView { private int lastSizeChangedWidth = -1; private int lastSizeChangedHeight = -1; + // Utilized for async text loading when a large number of emoji is present. + private int taskNumber = 0; + private Executor backgroundExecutor = new SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED); + private CharSequence requestedText = null; + private BufferType requestedType = null; + private MentionRendererDelegate mentionRendererDelegate; private SpoilerRendererDelegate spoilerRendererDelegate; @@ -128,7 +141,7 @@ public class EmojiTextView extends AppCompatTextView { public void setMaxLength(int maxLength) { this.maxLength = maxLength; - setText(getText()); + setTextAsync(getText()); } @Override @@ -162,8 +175,115 @@ public class EmojiTextView extends AppCompatTextView { } } + /** + * Recommended method for calling through to reset the text flow within this file. + * Doing so will ensure we call setTextAsync with the requested arguments as necessary. + */ + private void resetText() { + if (requestedText == null || requestedType == null) { + return; + } + + setTextAsync(requestedText, requestedType); + } + + public void setTextAsync(@Nullable CharSequence text) { + setTextAsync(text, BufferType.SPANNABLE); + } + + /** + * Sets the text. If there are more than 100 emoji candidates, we utilize PrecomputedTextCompat. + */ + public void setTextAsync(@Nullable CharSequence text, BufferType type) { + taskNumber++; + final int number = taskNumber; + + EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text); + if (candidates == null || candidates.size() <= 100) { + setText(text, type); + + if (sizeChangeInProgress) { + sizeChangeInProgress = false; + } + + return; + } + + final PrecomputedTextCompat.Params params = getTextMetricsParamsCompat(); + final Reference ref = new WeakReference<>(this); + + backgroundExecutor.execute(() -> { + EmojiTextView textView = ref.get(); + if (textView != null) { + + final CharSequence textToSet; + synchronized (textView) { + textToSet = getTextToSet(text, type); + } + + if (textToSet == null) { + return; + } + + final PrecomputedTextCompat precomputedTextCompat = PrecomputedTextCompat.create(textToSet, params); + + textView.post(() -> { + if (textView.taskNumber != number) { + return; + } + + textView.setPrecomputedText(precomputedTextCompat); + + if (textView.sizeChangeInProgress) { + textView.sizeChangeInProgress = false; + } + }); + } + }); + } + + /** + * Note if you aren't sure how many emoji are going to be displayed, it may be better to utilize [setTextAsync] + */ @Override public void setText(@Nullable CharSequence text, BufferType type) { + boolean isPrecomputed = (text instanceof PrecomputedTextCompat || (Build.VERSION.SDK_INT >= 28 && text instanceof PrecomputedText)); + if (!isPrecomputed) { + text = getTextToSet(text, type); + } + + if (text == null) { + return; + } + + super.setText(text, BufferType.SPANNABLE); + + previousText = text; + previousBufferType = type; + previousOverflowText = overflowText; + useSystemEmoji = useSystemEmoji(); + previousTransformationMethod = getTransformationMethod(); + + // Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688) + // We ellipsize them ourselves by manually truncating the appropriate section. + if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) { + if (getMaxLines() > 0 && getMaxLines() != Integer.MAX_VALUE) { + ellipsizeEmojiTextForMaxLines(); + } else if (maxLength > 0) { + ellipsizeAnyTextForMaxLength(); + } + } + + if (getLayoutParams() != null && getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) { + requestLayout(); + } + } + + private @Nullable CharSequence getTextToSet(@Nullable CharSequence text, BufferType type) { + if (text == null) { + return ""; + } + EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text); if (scaleEmojis && @@ -187,15 +307,9 @@ public class EmojiTextView extends AppCompatTextView { } if (unchanged(text, overflowText, type)) { - return; + return null; } - previousText = text; - previousOverflowText = overflowText; - previousBufferType = type; - useSystemEmoji = useSystemEmoji(); - previousTransformationMethod = getTransformationMethod(); - Spannable textToSet; if (useSystemEmoji || candidates == null || candidates.size() == 0) { textToSet = new SpannableStringBuilder(Optional.ofNullable(text).orElse("")); @@ -203,21 +317,7 @@ public class EmojiTextView extends AppCompatTextView { textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji)); } - super.setText(textToSet, BufferType.SPANNABLE); - - // Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688) - // We ellipsize them ourselves by manually truncating the appropriate section. - if (getText() != null && getText().length() > 0 && isEllipsizedAtEnd()) { - if (getMaxLines() > 0 && getMaxLines() != Integer.MAX_VALUE) { - ellipsizeEmojiTextForMaxLines(); - } else if (maxLength > 0) { - ellipsizeAnyTextForMaxLength(); - } - } - - if (getLayoutParams() != null && getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) { - requestLayout(); - } + return textToSet; } /** @@ -288,12 +388,15 @@ public class EmojiTextView extends AppCompatTextView { CharSequence text = getText(); if (text != null) { int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); - int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); + if (widthSpecMode != MeasureSpec.AT_MOST) { + return widthMeasureSpec; + } + int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); float measuredTextWidth = hasMetricAffectingSpan(text) ? Layout.getDesiredWidth(text, getPaint()) : getLongestLineWidth(text); int desiredWidth = (int) measuredTextWidth + getPaddingLeft() + getPaddingRight(); - if (widthSpecMode == MeasureSpec.AT_MOST && desiredWidth < widthSpecSize) { + if (desiredWidth < widthSpecSize) { return MeasureSpec.makeMeasureSpec(desiredWidth + 3, MeasureSpec.EXACTLY); } } @@ -338,7 +441,8 @@ public class EmojiTextView extends AppCompatTextView { public void setOverflowText(@Nullable CharSequence overflowText) { this.overflowText = overflowText; - setText(previousText, BufferType.SPANNABLE); + this.requestedType = BufferType.SPANNABLE; + resetText(); } @SuppressLint("ClickableViewAccessibility") @@ -472,8 +576,7 @@ public class EmojiTextView extends AppCompatTextView { if (!sizeChangeInProgress) { sizeChangeInProgress = true; - setText(previousText, previousBufferType); - sizeChangeInProgress = false; + resetText(); } } 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 71e8f064f2..86d8ccbe94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -1089,7 +1089,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyText.setMaxLines(Integer.MAX_VALUE); } - bodyText.setText(StringUtil.trim(styledText)); + bodyText.setTextAsync(StringUtil.trim(styledText)); bodyText.setVisibility(View.VISIBLE); if (conversationMessage.getBottomButton() != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java index a14b706d83..5822a7fdf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java @@ -129,9 +129,10 @@ public class LongMessageFragment extends FullScreenDialogFragment { CommunicationActions.handlePotentialProxyLinkUrl(requireActivity(), url)); bubble.setVisibility(View.VISIBLE); - text.setText(body); text.setMovementMethod(LongClickMovementMethod.getInstance(getContext())); text.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().getMessageFontSize()); + text.setTextAsync(body); + if (!message.get().getMessageRecord().isOutgoing()) { text.setMentionBackgroundTint(ContextCompat.getColor(requireContext(), ThemeUtil.isDarkTheme(requireActivity()) ? R.color.core_grey_60 : R.color.core_grey_20)); } else {