Fix ANR when trying to load a message composed of 10,000 random emoji.

This commit is contained in:
Alex Hart
2025-10-09 12:02:16 -03:00
committed by Cody Henthorne
parent f8eaa96412
commit c9a59a7417
3 changed files with 134 additions and 30 deletions

View File

@@ -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<EmojiTextView> 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();
}
}

View File

@@ -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) {

View File

@@ -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 {