From a64bffd83a198b11103f501cf5d36cf95285c7e9 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 17 May 2023 13:44:14 -0400 Subject: [PATCH] Complete text formatting. --- .../securesms/components/ComposeText.java | 9 +- .../securesms/components/QuoteView.java | 3 - .../components/emoji/EmojiTextView.java | 30 ++-- .../components/emoji/SimpleEmojiTextView.kt | 28 ++- .../emoji/SpoilerFilteringSpannableFactory.kt | 21 --- .../components/spoiler/SpoilerAnnotation.kt | 20 +-- .../components/spoiler/SpoilerRenderer.kt | 166 ++++++++++-------- .../spoiler/SpoilerRendererDelegate.kt | 31 ++-- .../conversation/ConversationItem.java | 2 - .../ConversationListItem.java | 2 - .../longmessage/LongMessageFragment.java | 1 - .../securesms/util/FeatureFlags.java | 12 -- .../util/LongClickMovementMethod.java | 34 +++- .../util/SpoilerFilteringSpannable.kt | 75 -------- .../res/values-night/material3_colors.xml | 1 + app/src/main/res/values/material3_colors.xml | 1 + .../main/res/values/material3_colors_dark.xml | 1 + .../res/values/material3_colors_light.xml | 1 + .../securesms/SpinnerApplicationContext.kt | 3 +- .../database/MessageRangesTransformer.kt | 41 +++++ 20 files changed, 211 insertions(+), 271 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/emoji/SpoilerFilteringSpannableFactory.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/SpoilerFilteringSpannable.kt create mode 100644 app/src/spinner/java/org/thoughtcrime/securesms/database/MessageRangesTransformer.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java index 3cd2929114..f40ae84a9a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -302,9 +302,7 @@ public class ComposeText extends EmojiEditText { addTextChangedListener(mentionValidatorWatcher); if (FeatureFlags.textFormatting()) { - if (FeatureFlags.textFormattingSpoilerSend()) { - spoilerRendererDelegate = new SpoilerRendererDelegate(this, true); - } + spoilerRendererDelegate = new SpoilerRendererDelegate(this, true); addTextChangedListener(new ComposeTextStyleWatcher()); @@ -323,10 +321,7 @@ public class ComposeText extends EmojiEditText { menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic)); menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough)); menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace)); - - if (FeatureFlags.textFormattingSpoilerSend()) { - menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler)); - } + menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler)); return true; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java index a7d4e16360..f9d8210053 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -5,7 +5,6 @@ import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; import android.os.Build; -import android.text.Spannable; import android.text.TextUtils; import android.util.AttributeSet; import android.view.View; @@ -31,7 +30,6 @@ import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme; -import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation; import org.thoughtcrime.securesms.conversation.MessageStyler; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; @@ -166,7 +164,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { setMessageType(messageType); - bodyView.enableSpoilerFiltering(); dismissView.setOnClickListener(view -> setVisibility(GONE)); } 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 914e43411a..65057eb1c6 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 @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.components.emoji; import android.content.Context; import android.content.res.TypedArray; import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.Region; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.Annotation; @@ -70,9 +72,8 @@ public class EmojiTextView extends AppCompatTextView { private boolean forceJumboEmoji; private boolean isInOnDraw; - private MentionRendererDelegate mentionRendererDelegate; - private final SpoilerRendererDelegate spoilerRendererDelegate; - private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory; + private MentionRendererDelegate mentionRendererDelegate; + private final SpoilerRendererDelegate spoilerRendererDelegate; public EmojiTextView(Context context) { this(context, null); @@ -113,11 +114,6 @@ public class EmojiTextView extends AppCompatTextView { setText(getText()); } - public void enableSpoilerFiltering() { - spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory(() -> isInOnDraw); - setSpannableFactory(spoilerFilteringSpannableFactory); - } - @Override protected void onDraw(Canvas canvas) { isInOnDraw = true; @@ -126,7 +122,10 @@ public class EmojiTextView extends AppCompatTextView { boolean hasLayout = getLayout() != null; if (hasSpannedText && hasLayout) { - drawSpecialRenderers(canvas, mentionRendererDelegate, spoilerRendererDelegate); + Path textClipPath = drawSpecialRenderers(canvas, mentionRendererDelegate, spoilerRendererDelegate); + canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop()); + canvas.clipPath(textClipPath, Region.Op.DIFFERENCE); + canvas.translate(-getTotalPaddingLeft(), -getTotalPaddingTop()); } super.onDraw(canvas); @@ -138,14 +137,14 @@ public class EmojiTextView extends AppCompatTextView { isInOnDraw = false; } - private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) { + private Path drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) { int checkpoint = canvas.save(); canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop()); try { if (mentionDelegate != null) { mentionDelegate.draw(canvas, (Spanned) getText(), getLayout()); } - spoilerDelegate.draw(canvas, (Spanned) getText(), getLayout()); + return spoilerDelegate.draw(canvas, (Spanned) getText(), getLayout()); } finally { canvas.restoreToCount(checkpoint); } @@ -187,9 +186,6 @@ public class EmojiTextView extends AppCompatTextView { textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji)); } - if (spoilerFilteringSpannableFactory != null) { - textToSet = spoilerFilteringSpannableFactory.wrap(textToSet); - } super.setText(textToSet, BufferType.SPANNABLE); // Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688) @@ -333,11 +329,7 @@ public class EmojiTextView extends AppCompatTextView { newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji); } - if (spoilerFilteringSpannableFactory != null) { - spoilerFilteringSpannableFactory.wrap(newTextToSet); - } - - super.setText(newContent, BufferType.SPANNABLE); + super.setText(newTextToSet, BufferType.SPANNABLE); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt index 8d5efdfdb4..07a292cf56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SimpleEmojiTextView.kt @@ -2,7 +2,8 @@ package org.thoughtcrime.securesms.components.emoji import android.content.Context import android.graphics.Canvas -import android.text.Spannable +import android.graphics.Path +import android.graphics.Region import android.text.Spanned import android.text.TextUtils import android.util.AttributeSet @@ -21,8 +22,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor( private var bufferType: BufferType? = null private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200) private val spoilerRendererDelegate: SpoilerRendererDelegate - private var spoilerFilteringSpannableFactory: SpoilerFilteringSpannableFactory? = null - private var isInOnDraw: Boolean = false init { isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji @@ -30,20 +29,24 @@ open class SimpleEmojiTextView @JvmOverloads constructor( } override fun onDraw(canvas: Canvas) { - isInOnDraw = true - + var textClipPath: Path? = null if (text is Spanned && layout != null) { val checkpoint = canvas.save() canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat()) try { - spoilerRendererDelegate.draw(canvas, (text as Spanned), layout) + textClipPath = spoilerRendererDelegate.draw(canvas, (text as Spanned), layout) } finally { canvas.restoreToCount(checkpoint) } } - super.onDraw(canvas) - isInOnDraw = false + if (textClipPath != null) { + canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat()) + canvas.clipPath(textClipPath, Region.Op.DIFFERENCE) + canvas.translate(-totalPaddingLeft.toFloat(), -totalPaddingTop.toFloat()) + } + + super.onDraw(canvas) } override fun setText(text: CharSequence?, type: BufferType?) { @@ -69,10 +72,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor( TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null) } - if (newContent is Spannable && spoilerFilteringSpannableFactory != null) { - newContent = spoilerFilteringSpannableFactory!!.wrap(newContent) - } - bufferType = BufferType.SPANNABLE super.setText(newContent, type) } @@ -86,9 +85,4 @@ open class SimpleEmojiTextView @JvmOverloads constructor( } } } - - fun enableSpoilerFiltering() { - spoilerFilteringSpannableFactory = SpoilerFilteringSpannableFactory { isInOnDraw } - setSpannableFactory(spoilerFilteringSpannableFactory!!) - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SpoilerFilteringSpannableFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SpoilerFilteringSpannableFactory.kt deleted file mode 100644 index 5e8230f70c..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/SpoilerFilteringSpannableFactory.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.thoughtcrime.securesms.components.emoji - -import android.text.Spannable -import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable -import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable.InOnDrawProvider - -/** - * Spannable factory used to help ensure spans are copied/maintained properly through the - * Android text handling system. - * - * @param inOnDraw Used by [SpoilerFilteringSpannable] to remove spans when being called from onDraw - */ -class SpoilerFilteringSpannableFactory(private val inOnDraw: InOnDrawProvider) : Spannable.Factory() { - override fun newSpannable(source: CharSequence): Spannable { - return wrap(super.newSpannable(source)) - } - - fun wrap(source: Spannable): SpoilerFilteringSpannable { - return SpoilerFilteringSpannable(source, inOnDraw) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt index bb87b7914e..22c60760e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerAnnotation.kt @@ -1,10 +1,8 @@ package org.thoughtcrime.securesms.components.spoiler -import android.graphics.Color import android.text.Annotation import android.text.Selection import android.text.Spannable -import android.text.SpannableString import android.text.Spanned import android.text.TextPaint import android.text.style.ClickableSpan @@ -63,21 +61,15 @@ object SpoilerAnnotation { override fun onClick(widget: View) { revealedSpoilers.add(spoiler.value) - if (widget is TextView && Selection.getSelectionStart(widget.text) != -1) { - val text: Spannable = if (widget.text is Spannable) { - widget.text as Spannable - } else { - SpannableString(widget.text) + + if (widget is TextView) { + val text = widget.text + if (text is Spannable) { + Selection.removeSelection(text) } - Selection.removeSelection(text) - widget.text = text } } - override fun updateDrawState(ds: TextPaint) { - if (!spoilerRevealed) { - ds.color = Color.TRANSPARENT - } - } + override fun updateDrawState(ds: TextPaint) = Unit } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRenderer.kt index b61ea25867..9251b66b3a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRenderer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRenderer.kt @@ -1,109 +1,123 @@ package org.thoughtcrime.securesms.components.spoiler import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path import android.text.Layout +import androidx.annotation.ColorInt +import androidx.annotation.Px import org.thoughtcrime.securesms.util.LayoutUtil /** * Handles drawing the spoiler sparkles for a TextView. */ -abstract class SpoilerRenderer { +class SpoilerRenderer( + private val spoilerDrawable: SpoilerDrawable, + private val renderForComposing: Boolean, + @Px private val padding: Int, + @ColorInt composeBackgroundColor: Int +) { - abstract fun draw( + private val lineTopCache = HashMap() + private val lineBottomCache = HashMap() + private val paint = Paint().apply { color = composeBackgroundColor } + + fun draw( canvas: Canvas, layout: Layout, startLine: Int, endLine: Int, startOffset: Int, - endOffset: Int - ) - - protected fun getLineTop(layout: Layout, line: Int): Int { - return LayoutUtil.getLineTopWithoutPadding(layout, line) - } - - protected fun getLineBottom(layout: Layout, line: Int): Int { - return LayoutUtil.getLineBottomWithoutPadding(layout, line) - } - - protected inline fun MutableMap.get(line: Int, layout: Layout, default: () -> Int): Int { - return getOrPut(line * 31 + layout.hashCode() * 31, default) - } - - class SingleLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() { - private val lineTopCache = HashMap() - private val lineBottomCache = HashMap() - - override fun draw( - canvas: Canvas, - layout: Layout, - startLine: Int, - endLine: Int, - startOffset: Int, - endOffset: Int - ) { + endOffset: Int, + textClipPath: Path + ) { + if (startLine == endLine) { val lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) } val lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) } val left = startOffset.coerceAtMost(endOffset) val right = startOffset.coerceAtLeast(endOffset) - spoilerDrawable.setBounds(left, lineTop, right, lineBottom) - spoilerDrawable.draw(canvas) - } - } - - class MultiLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() { - private val lineTopCache = HashMap() - private val lineBottomCache = HashMap() - - override fun draw( - canvas: Canvas, - layout: Layout, - startLine: Int, - endLine: Int, - startOffset: Int, - endOffset: Int - ) { - val paragraphDirection = layout.getParagraphDirection(startLine) - - val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine) - var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) } - var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) } - drawStart(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom) - - for (line in startLine + 1 until endLine) { - val left: Int = layout.getLineLeft(line).toInt() - val right: Int = layout.getLineRight(line).toInt() - - lineTop = getLineTop(layout, line) - lineBottom = getLineBottom(layout, line) - + if (renderForComposing) { + canvas.drawComposeBackground(left, lineTop, right, lineBottom) + } else { + textClipPath.addRect(left, lineTop, right, lineBottom) spoilerDrawable.setBounds(left, lineTop, right, lineBottom) spoilerDrawable.draw(canvas) } - val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine) - lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) } - lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) } - drawEnd(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom) + return } - private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) { - if (start > end) { - spoilerDrawable.setBounds(end, top, start, bottom) + val paragraphDirection = layout.getParagraphDirection(startLine) + + val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine) + var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) } + var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) } + drawPartialLine(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom, textClipPath) + + for (line in startLine + 1 until endLine) { + val left: Int = layout.getLineLeft(line).toInt() + val right: Int = layout.getLineRight(line).toInt() + + lineTop = getLineTop(layout, line) + lineBottom = getLineBottom(layout, line) + + if (renderForComposing) { + canvas.drawComposeBackground(left, lineTop, right, lineBottom) } else { - spoilerDrawable.setBounds(start, top, end, bottom) + textClipPath.addRect(left, lineTop, right, lineBottom) + spoilerDrawable.setBounds(left, lineTop, right, lineBottom) + spoilerDrawable.draw(canvas) } - spoilerDrawable.draw(canvas) } - private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) { - if (start > end) { - spoilerDrawable.setBounds(end, top, start, bottom) - } else { - spoilerDrawable.setBounds(start, top, end, bottom) - } - spoilerDrawable.draw(canvas) + val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine) + lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) } + lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) } + drawPartialLine(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom, textClipPath) + } + + private fun drawPartialLine(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int, textClipPath: Path) { + if (renderForComposing) { + canvas.drawComposeBackground(start, top, end, bottom) + return } + + if (start > end) { + textClipPath.addRect(end, top, start, bottom) + spoilerDrawable.setBounds(end, top, start, bottom) + } else { + textClipPath.addRect(start, top, end, bottom) + spoilerDrawable.setBounds(start, top, end, bottom) + } + spoilerDrawable.draw(canvas) + } + + private fun getLineTop(layout: Layout, line: Int): Int { + return LayoutUtil.getLineTopWithoutPadding(layout, line) + } + + private fun getLineBottom(layout: Layout, line: Int): Int { + return LayoutUtil.getLineBottomWithoutPadding(layout, line) + } + + private inline fun MutableMap.get(line: Int, layout: Layout, default: () -> Int): Int { + return getOrPut(line * 31 + layout.hashCode() * 31, default) + } + + private fun Canvas.drawComposeBackground(start: Int, top: Int, end: Int, bottom: Int) { + drawRoundRect( + start.toFloat() - padding, + top.toFloat() - padding, + end.toFloat() + padding, + bottom.toFloat(), + padding.toFloat(), + padding.toFloat(), + paint + ) + } + + private fun Path.addRect(left: Int, top: Int, end: Int, bottom: Int) { + addRect(left.toFloat() - padding, top.toFloat() - padding, end.toFloat() + padding, bottom.toFloat() + padding, Path.Direction.CCW) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt index dff67a4bd3..d5b2101153 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/spoiler/SpoilerRendererDelegate.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.spoiler import android.animation.ValueAnimator import android.graphics.Canvas +import android.graphics.Path import android.text.Annotation import android.text.Layout import android.text.Spanned @@ -9,18 +10,17 @@ import android.view.View import android.view.View.OnAttachStateChangeListener import android.view.animation.LinearInterpolator import android.widget.TextView +import androidx.core.content.ContextCompat +import org.signal.core.util.dp +import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation.SpoilerClickableSpan -import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.MultiLineSpoilerRenderer -import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.SingleLineSpoilerRenderer /** - * Performs initial calculation on how to render spoilers and then delegates to the single line or - * multi-line version of actually drawing the spoiler sparkles. + * Performs initial calculation on how to render spoilers and then delegates to actually drawing the spoiler sparkles. */ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextView, private val renderForComposing: Boolean = false) { - private val single: SpoilerRenderer - private val multi: SpoilerRenderer + private val renderer: SpoilerRenderer private val spoilerDrawable: SpoilerDrawable private var animatorRunning = false private var textColor: Int @@ -39,11 +39,17 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi repeatMode = ValueAnimator.REVERSE } + private val textClipPath: Path = Path() + init { textColor = view.textColors.defaultColor spoilerDrawable = SpoilerDrawable(textColor) - single = SingleLineSpoilerRenderer(spoilerDrawable) - multi = MultiLineSpoilerRenderer(spoilerDrawable) + renderer = SpoilerRenderer( + spoilerDrawable = spoilerDrawable, + renderForComposing = renderForComposing, + padding = 2.dp, + composeBackgroundColor = ContextCompat.getColor(view.context, R.color.signal_colorOnSurfaceVariant1) + ) view.addOnAttachStateChangeListener(object : OnAttachStateChangeListener { override fun onViewDetachedFromWindow(v: View) = stopAnimating() @@ -59,10 +65,11 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi } } - fun draw(canvas: Canvas, text: Spanned, layout: Layout) { + fun draw(canvas: Canvas, text: Spanned, layout: Layout): Path { var hasSpoilersToRender = false val annotations: Map = cachedAnnotations.getFromCache(text) { SpoilerAnnotation.getSpoilerAndClickAnnotations(text) } + textClipPath.reset() for ((annotation, clickSpan) in annotations.entries) { if (clickSpan?.spoilerRevealed == true) { continue @@ -85,9 +92,7 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi ) } - val renderer: SpoilerRenderer = if (measurements.startLine == measurements.endLine) single else multi - - renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset) + renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset, textClipPath) hasSpoilersToRender = true } @@ -99,6 +104,8 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi } else { stopAnimating() } + + return textClipPath } private fun stopAnimating() { 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 9d8bc95a89..30148952d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -91,7 +91,6 @@ import org.thoughtcrime.securesms.components.ThumbnailView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.contactshare.Contact; -import org.thoughtcrime.securesms.conversation.colors.ChatColors; import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; @@ -342,7 +341,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo bodyText.setOnLongClickListener(passthroughClickListener); bodyText.setOnClickListener(passthroughClickListener); - bodyText.enableSpoilerFiltering(); footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index d28386d2a0..a9eb89fd82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -171,8 +171,6 @@ public final class ConversationListItem extends ConstraintLayout implements Bind this.thumbTarget = new GlideLiveDataTarget(thumbSize, thumbSize); this.searchStyleFactory = () -> new CharacterStyle[] { new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface)), SpanUtil.getBoldSpan() }; - this.subjectView.enableSpoilerFiltering(); - getLayoutTransition().setDuration(150); } 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 8a45c36b60..a5736a9cd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java @@ -130,7 +130,6 @@ public class LongMessageFragment extends FullScreenDialogFragment { SpannableString styledBody = linkifyMessageBody(new SpannableString(trimmedBody)); bubble.setVisibility(View.VISIBLE); - text.enableSpoilerFiltering(); text.setText(styledBody); text.setMovementMethod(LinkMovementMethod.getInstance()); text.setTextSize(TypedValue.COMPLEX_UNIT_SP, SignalStore.settings().getMessageFontSize()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 5dd3db8bf8..b0072c0c83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -15,8 +15,6 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.SelectionLimits; -import org.thoughtcrime.securesms.jobs.RefreshAttributesJob; -import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob; import org.thoughtcrime.securesms.jobs.RemoteConfigRefreshJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver; @@ -107,7 +105,6 @@ public final class FeatureFlags { private static final String TEXT_FORMATTING = "android.textFormatting"; private static final String ANY_ADDRESS_PORTS_KILL_SWITCH = "android.calling.fieldTrial.anyAddressPortsKillSwitch"; private static final String CALLS_TAB = "android.calls.tab.2"; - private static final String TEXT_FORMATTING_SPOILER_SEND = "android.textFormatting.spoilerSend"; private static final String AD_HOC_CALLING = "android.calling.ad.hoc"; private static final String EDIT_MESSAGE_RECEIVE = "android.editMessage.receive"; private static final String EDIT_MESSAGE_SEND = "android.editMessage.send"; @@ -170,7 +167,6 @@ public final class FeatureFlags { TEXT_FORMATTING, ANY_ADDRESS_PORTS_KILL_SWITCH, CALLS_TAB, - TEXT_FORMATTING_SPOILER_SEND, EDIT_MESSAGE_RECEIVE, EDIT_MESSAGE_SEND ); @@ -238,7 +234,6 @@ public final class FeatureFlags { PAYMENTS_REQUEST_ACTIVATE_FLOW, CDS_HARD_LIMIT, TEXT_FORMATTING, - TEXT_FORMATTING_SPOILER_SEND, EDIT_MESSAGE_RECEIVE, EDIT_MESSAGE_SEND ); @@ -587,13 +582,6 @@ public final class FeatureFlags { return getBoolean(TEXT_FORMATTING, false); } - /** - * Whether or not we should show spoiler text formatting option. - */ - public static boolean textFormattingSpoilerSend() { - return getBoolean(TEXT_FORMATTING_SPOILER_SEND, false); - } - /** * Enable/disable RingRTC field trial for "AnyAddressPortsKillSwitch" */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java b/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java index 432ee1b036..95d01b9850 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/LongClickMovementMethod.java @@ -15,6 +15,7 @@ import android.widget.TextView; import androidx.core.content.ContextCompat; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation; import java.lang.ref.WeakReference; @@ -53,8 +54,7 @@ public class LongClickMovementMethod extends LinkMovementMethod { public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { int action = event.getAction(); - if (action == MotionEvent.ACTION_UP || - action == MotionEvent.ACTION_DOWN) { + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { int x = (int) event.getX(); int y = (int) event.getY(); @@ -68,14 +68,31 @@ public class LongClickMovementMethod extends LinkMovementMethod { int line = layout.getLineForVertical(y); int off = layout.getOffsetForHorizontal(line, x); - LongClickCopySpan longClickCopySpan[] = buffer.getSpans(off, off, LongClickCopySpan.class); + SpoilerAnnotation.SpoilerClickableSpan[] spoilerClickableSpans = buffer.getSpans(off, off, SpoilerAnnotation.SpoilerClickableSpan.class); + if (spoilerClickableSpans.length != 0) { + boolean spoilerRevealed = false; + for (SpoilerAnnotation.SpoilerClickableSpan spoilerClickSpan : spoilerClickableSpans) { + if (!spoilerClickSpan.getSpoilerRevealed() && action == MotionEvent.ACTION_DOWN) { + return true; + } + + if (!spoilerClickSpan.getSpoilerRevealed() && action == MotionEvent.ACTION_UP) { + spoilerClickSpan.onClick(widget); + spoilerRevealed = true; + } + } + + if (spoilerRevealed) { + return true; + } + } + + LongClickCopySpan[] longClickCopySpan = buffer.getSpans(off, off, LongClickCopySpan.class); if (longClickCopySpan.length != 0) { LongClickCopySpan aSingleSpan = longClickCopySpan[0]; if (action == MotionEvent.ACTION_DOWN) { - Selection.setSelection(buffer, buffer.getSpanStart(aSingleSpan), - buffer.getSpanEnd(aSingleSpan)); - aSingleSpan.setHighlighted(true, - ContextCompat.getColor(widget.getContext(), R.color.touch_highlight)); + Selection.setSelection(buffer, buffer.getSpanStart(aSingleSpan), buffer.getSpanEnd(aSingleSpan)); + aSingleSpan.setHighlighted(true, ContextCompat.getColor(widget.getContext(), R.color.touch_highlight)); } else { Selection.removeSelection(buffer); aSingleSpan.setHighlighted(false, Color.TRANSPARENT); @@ -89,8 +106,7 @@ public class LongClickMovementMethod extends LinkMovementMethod { } } else if (action == MotionEvent.ACTION_CANCEL) { // Remove Selections. - LongClickCopySpan[] spans = buffer.getSpans(Selection.getSelectionStart(buffer), - Selection.getSelectionEnd(buffer), LongClickCopySpan.class); + LongClickCopySpan[] spans = buffer.getSpans(Selection.getSelectionStart(buffer), Selection.getSelectionEnd(buffer), LongClickCopySpan.class); for (LongClickCopySpan aSpan : spans) { aSpan.setHighlighted(false, Color.TRANSPARENT); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SpoilerFilteringSpannable.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SpoilerFilteringSpannable.kt deleted file mode 100644 index e444ddb2ab..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SpoilerFilteringSpannable.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.thoughtcrime.securesms.util - -import android.text.Annotation -import android.text.Spannable -import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation - -/** - * Filters the results of [getSpans] to exclude spans covered by an unrevealed spoiler when drawing or - * processing clicks. Since [getSpans] can also be called when making copies of spannables, we do not filter - * the call unless we know we are drawing or getting click spannables. - */ -class SpoilerFilteringSpannable(private val spannable: Spannable, private val inOnDrawProvider: InOnDrawProvider) : Spannable by spannable { - - override fun getSpans(start: Int, end: Int, type: Class): Array { - val spans: Array = spannable.getSpans(start, end, type) - - if (spans.isEmpty() || !(inOnDrawProvider.isInOnDraw() || type == LongClickCopySpan::class.java)) { - return spans - } - - if (spannable.getSpans(0, spannable.length, Annotation::class.java).none { SpoilerAnnotation.isSpoilerAnnotation(it) }) { - return spans - } - - val spansToExclude = HashSet() - val spoilers: Map = SpoilerAnnotation.getSpoilerAndClickAnnotations(spannable, start, end) - val allOtherTheSpans: Map> = spans - .filterNot { SpoilerAnnotation.isSpoilerAnnotation(it) || it is SpoilerAnnotation.SpoilerClickableSpan } - .associateWith { (spannable.getSpanStart(it) to spannable.getSpanEnd(it)) } - - spoilers.forEach { (spoiler, click) -> - if (click?.spoilerRevealed == true) { - spansToExclude += spoiler - spansToExclude += click - } else { - val spoilerStart = spannable.getSpanStart(spoiler) - val spoilerEnd = spannable.getSpanEnd(spoiler) - - for ((span, position) in allOtherTheSpans) { - if (position.first in spoilerStart..spoilerEnd) { - spansToExclude += span - } else if (position.second in spoilerStart..spoilerEnd) { - spansToExclude += span - } - } - } - } - - return spans.filter(spansToExclude) - } - - /** - * Kotlin does not handle generic JVM arrays well so instead of using all the nice collection functions - * we do a move desired objects down and overwrite undesired objects and then copy the array to trim - * it to the correct length. For our use case, it's okay to modify the original array. - */ - private fun Array.filter(set: Set): Array { - var index = 0 - for (i in this.indices) { - this[index] = this[i] - if (!set.contains(this[index])) { - index++ - } - } - return copyOfRange(0, index) - } - - override fun toString(): String = spannable.toString() - override fun hashCode(): Int = spannable.hashCode() - override fun equals(other: Any?): Boolean = spannable == other - - fun interface InOnDrawProvider { - fun isInOnDraw(): Boolean - } -} diff --git a/app/src/main/res/values-night/material3_colors.xml b/app/src/main/res/values-night/material3_colors.xml index ed3e98cb2b..b80f6adac3 100644 --- a/app/src/main/res/values-night/material3_colors.xml +++ b/app/src/main/res/values-night/material3_colors.xml @@ -16,6 +16,7 @@ @color/signal_dark_colorOnSecondaryContainer @color/signal_dark_colorOnSurface @color/signal_dark_colorOnSurfaceVariant + @color/signal_dark_colorOnSurfaceVariant1 @color/signal_dark_colorOnBackground @color/signal_dark_colorOutline @color/signal_dark_neutralSurface diff --git a/app/src/main/res/values/material3_colors.xml b/app/src/main/res/values/material3_colors.xml index b027ee86a0..bef00a3662 100644 --- a/app/src/main/res/values/material3_colors.xml +++ b/app/src/main/res/values/material3_colors.xml @@ -16,6 +16,7 @@ @color/signal_light_colorOnSecondaryContainer @color/signal_light_colorOnSurface @color/signal_light_colorOnSurfaceVariant + @color/signal_light_colorOnSurfaceVariant1 @color/signal_light_colorOnBackground @color/signal_light_colorOutline @color/signal_light_neutralSurface diff --git a/app/src/main/res/values/material3_colors_dark.xml b/app/src/main/res/values/material3_colors_dark.xml index 87450d05b5..57c34273fc 100644 --- a/app/src/main/res/values/material3_colors_dark.xml +++ b/app/src/main/res/values/material3_colors_dark.xml @@ -15,6 +15,7 @@ #DCE1F9 #E2E1E5 #BEBFC5 + #4D5059 #E2E1E5 #5C5E65 #14FFFFFF diff --git a/app/src/main/res/values/material3_colors_light.xml b/app/src/main/res/values/material3_colors_light.xml index fbfa60d672..9b1f569329 100644 --- a/app/src/main/res/values/material3_colors_light.xml +++ b/app/src/main/res/values/material3_colors_light.xml @@ -15,6 +15,7 @@ #151D2C #1B1B1D #545863 + #BBBFC8 #1B1D1D #808389 #99FFFFFF diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt index 2ca32db998..91e6a96aa5 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.LocalMetricsDatabase import org.thoughtcrime.securesms.database.LogDatabase import org.thoughtcrime.securesms.database.MegaphoneDatabase import org.thoughtcrime.securesms.database.MessageBitmaskColumnTransformer +import org.thoughtcrime.securesms.database.MessageRangesTransformer import org.thoughtcrime.securesms.database.ProfileKeyCredentialTransformer import org.thoughtcrime.securesms.database.QueryMonitor import org.thoughtcrime.securesms.database.SignalDatabase @@ -50,7 +51,7 @@ class SpinnerApplicationContext : ApplicationContext() { linkedMapOf( "signal" to DatabaseConfig( db = { SignalDatabase.rawDatabase }, - columnTransformers = listOf(MessageBitmaskColumnTransformer, GV2Transformer, GV2UpdateTransformer, IsStoryTransformer, TimestampTransformer, ProfileKeyCredentialTransformer) + columnTransformers = listOf(MessageBitmaskColumnTransformer, GV2Transformer, GV2UpdateTransformer, IsStoryTransformer, TimestampTransformer, ProfileKeyCredentialTransformer, MessageRangesTransformer) ), "jobmanager" to DatabaseConfig(db = { JobDatabase.getInstance(this).sqlCipherDatabase }), "keyvalue" to DatabaseConfig(db = { KeyValueDatabase.getInstance(this).sqlCipherDatabase }), diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageRangesTransformer.kt b/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageRangesTransformer.kt new file mode 100644 index 0000000000..3fd3175d7c --- /dev/null +++ b/app/src/spinner/java/org/thoughtcrime/securesms/database/MessageRangesTransformer.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.database + +import android.database.Cursor +import org.signal.core.util.requireBlob +import org.signal.spinner.ColumnTransformer +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList + +object MessageRangesTransformer : ColumnTransformer { + override fun matches(tableName: String?, columnName: String): Boolean { + return columnName == MessageTable.MESSAGE_RANGES && (tableName == null || tableName == MessageTable.TABLE_NAME) + } + + override fun transform(tableName: String?, columnName: String, cursor: Cursor): String? { + val messageRangesData: ByteArray? = cursor.requireBlob(MessageTable.MESSAGE_RANGES) + + return if (messageRangesData != null) { + val ranges = BodyRangeList.parseFrom(messageRangesData) + ranges.rangesList + .map { range -> + val mention = range.hasMentionUuid() + val style = range.hasStyle() + val start = range.start + val length = range.length + + var rangeString = "
Type: ${if (mention) "mention" else "style"}
-start: $start
-length: $length" + + if (mention) { + rangeString += "
-uuid: ${range.mentionUuid}" + } + + if (style) { + rangeString += "
-style: ${range.style}" + } + + rangeString + }.joinToString("
") + } else { + null + } + } +}