package org.thoughtcrime.securesms.components.emoji; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Annotation; import android.text.Layout; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextDirectionHeuristic; import android.text.TextDirectionHeuristics; import android.text.TextUtils; import android.text.method.TransformationMethod; import android.text.style.CharacterStyle; import android.util.AttributeSet; import android.util.TypedValue; import android.view.ViewGroup; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatTextView; import androidx.core.content.ContextCompat; import androidx.core.view.ViewKt; import androidx.core.widget.TextViewCompat; import org.signal.core.util.StringUtil; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate; import org.thoughtcrime.securesms.emoji.JumboEmoji; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.Util; import java.util.Arrays; import java.util.List; import java.util.Optional; import kotlin.Unit; public class EmojiTextView extends AppCompatTextView { private final boolean scaleEmojis; private static final char ELLIPSIS = '…'; private static final float JUMBOMOJI_SCALE = 0.8f; private boolean forceCustom; private CharSequence previousText; private BufferType previousBufferType; private TransformationMethod previousTransformationMethod; private float originalFontSize; private boolean useSystemEmoji; private boolean sizeChangeInProgress; private int maxLength; private CharSequence overflowText; private CharSequence previousOverflowText; private boolean renderMentions; private boolean measureLastLine; private int lastLineWidth = -1; private TextDirectionHeuristic textDirection; private boolean isJumbomoji; private boolean forceJumboEmoji; private MentionRendererDelegate mentionRendererDelegate; public EmojiTextView(Context context) { this(context, null); } public EmojiTextView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public EmojiTextView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0); scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false); maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1); forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false); renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true); measureLastLine = a.getBoolean(R.styleable.EmojiTextView_measureLastLine, false); forceJumboEmoji = a.getBoolean(R.styleable.EmojiTextView_emoji_forceJumbo, false); a.recycle(); a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize}); originalFontSize = a.getDimensionPixelSize(0, 0); a.recycle(); if (renderMentions) { mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20)); } textDirection = getLayoutDirection() == LAYOUT_DIRECTION_LTR ? TextDirectionHeuristics.FIRSTSTRONG_RTL : TextDirectionHeuristics.ANYRTL_LTR; } @Override protected void onDraw(Canvas canvas) { if (renderMentions && getText() instanceof Spanned && getLayout() != null) { int checkpoint = canvas.save(); canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop()); try { mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout()); } finally { canvas.restoreToCount(checkpoint); } } super.onDraw(canvas); } @Override public void setText(@Nullable CharSequence text, BufferType type) { EmojiParser.CandidateList candidates = isInEditMode() ? null : EmojiProvider.getCandidates(text); if (scaleEmojis && candidates != null && candidates.allEmojis && (candidates.hasJumboForAll() || JumboEmoji.canDownloadJumbo(getContext()))) { int emojis = candidates.size(); float scale = 1.0f; if (emojis <= 5) scale += JUMBOMOJI_SCALE; if (emojis <= 4) scale += JUMBOMOJI_SCALE; if (emojis <= 2) scale += JUMBOMOJI_SCALE; isJumbomoji = scale > 1.0f; super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale); } else if (scaleEmojis) { isJumbomoji = false; super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize); } if (unchanged(text, overflowText, type)) { return; } previousText = text; previousOverflowText = overflowText; previousBufferType = type; useSystemEmoji = useSystemEmoji(); previousTransformationMethod = getTransformationMethod(); if (useSystemEmoji || candidates == null || candidates.size() == 0) { super.setText(new SpannableStringBuilder(Optional.ofNullable(text).orElse("")), BufferType.SPANNABLE); } else { CharSequence emojified = EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji); super.setText(new SpannableStringBuilder(emojified), 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) { ellipsizeEmojiTextForMaxLines(); } else if (maxLength > 0) { ellipsizeAnyTextForMaxLength(); } } if (getLayoutParams() != null && getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) { requestLayout(); } } /** * Used to determine whether to apply custom ellipsizing logic without necessarily having the * ellipsize property set. This allows us to work around implementations of Layout which apply an * ellipsis even when maxLines is not set. */ private boolean isEllipsizedAtEnd() { return getEllipsize() == TextUtils.TruncateAt.END || (getMaxLines() > 0 && getMaxLines() < Integer.MAX_VALUE) || maxLength > 0; } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { widthMeasureSpec = applyWidthMeasureRoundingFix(widthMeasureSpec); super.onMeasure(widthMeasureSpec, heightMeasureSpec); CharSequence text = getText(); if (getLayout() == null || !measureLastLine || text == null || text.length() == 0) { lastLineWidth = -1; } else { Layout layout = getLayout(); text = layout.getText(); int lines = layout.getLineCount(); int start = layout.getLineStart(lines - 1); if ((getLayoutDirection() == LAYOUT_DIRECTION_LTR && textDirection.isRtl(text, 0, text.length())) || (getLayoutDirection() == LAYOUT_DIRECTION_RTL && !textDirection.isRtl(text, 0, text.length()))) { lastLineWidth = getMeasuredWidth(); } else { lastLineWidth = (int) getPaint().measureText(text, start, text.length()); } } } /** * Starting from API 30, there can be a rounding error in text layout when a non-zero letter * spacing is used. This causes a line break to be inserted where there shouldn't be one. Force * the width to be larger to work around this problem. * https://issuetracker.google.com/issues/173574230 * * @param widthMeasureSpec the original measure spec passed to {@link #onMeasure(int, int)} * @return the measure spec with the workaround, or the original one. */ private int applyWidthMeasureRoundingFix(int widthMeasureSpec) { if (Build.VERSION.SDK_INT >= 30 && getLetterSpacing() > 0) { CharSequence text = getText(); if (text != null) { int widthSpecMode = MeasureSpec.getMode(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) { return MeasureSpec.makeMeasureSpec(desiredWidth + 1, MeasureSpec.EXACTLY); } } } return widthMeasureSpec; } private boolean hasMetricAffectingSpan(@NonNull CharSequence text) { if (!(text instanceof Spanned)) { return false; } return ((Spanned) text).nextSpanTransition(-1, text.length(), CharacterStyle.class) != text.length(); } private float getLongestLineWidth(@NonNull CharSequence text) { if (TextUtils.isEmpty(text)) { return 0f; } long maxLines = getMaxLines() > 0 ? getMaxLines() : Long.MAX_VALUE; return Arrays.stream(text.toString().split("\n")) .limit(maxLines) .map(s -> getPaint().measureText(s, 0, s.length())) .max(Float::compare) .orElse(0f); } public int getLastLineWidth() { return lastLineWidth; } public boolean isSingleLine() { return getLayout() != null && getLayout().getLineCount() == 1; } public boolean isJumbomoji() { return isJumbomoji; } public void setOverflowText(@Nullable CharSequence overflowText) { this.overflowText = overflowText; setText(previousText, BufferType.SPANNABLE); } public void setForceCustomEmoji(boolean forceCustom) { if (this.forceCustom != forceCustom) { this.forceCustom = forceCustom; setText(previousText, BufferType.SPANNABLE); } } private void ellipsizeAnyTextForMaxLength() { if (maxLength > 0 && getText().length() > maxLength + 1) { SpannableStringBuilder newContent = new SpannableStringBuilder(); CharSequence shortenedText = getText().subSequence(0, maxLength); if (shortenedText instanceof Spanned) { Spanned spanned = (Spanned) shortenedText; List mentionAnnotations = MentionAnnotation.getMentionAnnotations(spanned, maxLength - 1, maxLength); if (!mentionAnnotations.isEmpty()) { shortenedText = shortenedText.subSequence(0, spanned.getSpanStart(mentionAnnotations.get(0))); } } newContent.append(shortenedText) .append(ELLIPSIS) .append(Util.emptyIfNull(overflowText)); EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent); if (useSystemEmoji || newCandidates == null || newCandidates.size() == 0) { super.setText(newContent, BufferType.SPANNABLE); } else { CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji); super.setText(emojified, BufferType.SPANNABLE); } } } private void ellipsizeEmojiTextForMaxLines() { Runnable ellipsize = () -> { int maxLines = TextViewCompat.getMaxLines(EmojiTextView.this); if (maxLines <= 0 && maxLength < 0) { return; } int lineCount = getLineCount(); if (lineCount > maxLines) { int overflowStart = getLayout().getLineStart(maxLines - 1); if (maxLength > 0 && overflowStart > maxLength) { ellipsizeAnyTextForMaxLength(); return; } int overflowEnd = getLayout().getLineEnd(maxLines - 1); CharSequence overflow = getText().subSequence(overflowStart, overflowEnd); float adjust = overflowText != null ? getPaint().measureText(overflowText, 0, overflowText.length()) : 0f; CharSequence ellipsized = StringUtil.trim(TextUtils.ellipsize(overflow, getPaint(), getWidth() - adjust, TextUtils.TruncateAt.END)); SpannableStringBuilder newContent = new SpannableStringBuilder(); newContent.append(getText().subSequence(0, overflowStart)) .append(ellipsized.subSequence(0, ellipsized.length())) .append(Optional.ofNullable(overflowText).orElse("")); EmojiParser.CandidateList newCandidates = isInEditMode() ? null : EmojiProvider.getCandidates(newContent); CharSequence emojified = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji); super.setText(emojified, BufferType.SPANNABLE); } else if (maxLength > 0) { ellipsizeAnyTextForMaxLength(); } }; if (getLayout() != null) { ellipsize.run(); } else { ViewKt.doOnPreDraw(this, view -> { ellipsize.run(); return Unit.INSTANCE; }); } } private boolean unchanged(CharSequence text, CharSequence overflowText, BufferType bufferType) { return Util.equals(previousText, text) && Util.equals(previousOverflowText, overflowText) && Util.equals(previousBufferType, bufferType) && useSystemEmoji == useSystemEmoji() && !sizeChangeInProgress && previousTransformationMethod == getTransformationMethod(); } private boolean useSystemEmoji() { return !forceCustom && SignalStore.settings().isPreferSystemEmoji(); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (!sizeChangeInProgress) { sizeChangeInProgress = true; setText(previousText, previousBufferType); sizeChangeInProgress = false; } } @Override public void invalidateDrawable(@NonNull Drawable drawable) { if (drawable instanceof EmojiProvider.EmojiDrawable) invalidate(); else super.invalidateDrawable(drawable); } @Override public void setTextSize(float size) { setTextSize(TypedValue.COMPLEX_UNIT_SP, size); } @Override public void setTextSize(int unit, float size) { this.originalFontSize = TypedValue.applyDimension(unit, size, getResources().getDisplayMetrics()); super.setTextSize(unit, size); } public void setMentionBackgroundTint(@ColorInt int mentionBackgroundTint) { if (renderMentions) { mentionRendererDelegate.setTint(mentionBackgroundTint); } } }