Complete text formatting.

This commit is contained in:
Cody Henthorne 2023-05-17 13:44:14 -04:00 committed by Greyson Parrelli
parent 534c5c3c64
commit a64bffd83a
20 changed files with 211 additions and 271 deletions

View file

@ -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;
}

View file

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

View file

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

View file

@ -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!!)
}
}

View file

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

View file

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

View file

@ -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<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
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<Int, Int>.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<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
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<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
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<Int, Int>.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)
}
}

View file

@ -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<Annotation, SpoilerClickableSpan?> = 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() {

View file

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

View file

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

View file

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

View file

@ -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"
*/

View file

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

View file

@ -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 <T : Any> getSpans(start: Int, end: Int, type: Class<T>): Array<T> {
val spans: Array<T> = 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<Any>()
val spoilers: Map<Annotation, SpoilerAnnotation.SpoilerClickableSpan?> = SpoilerAnnotation.getSpoilerAndClickAnnotations(spannable, start, end)
val allOtherTheSpans: Map<T, Pair<Int, Int>> = 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 <T : Any> Array<T>.filter(set: Set<Any>): Array<T> {
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
}
}

View file

@ -16,6 +16,7 @@
<color name="signal_colorOnSecondaryContainer">@color/signal_dark_colorOnSecondaryContainer</color>
<color name="signal_colorOnSurface">@color/signal_dark_colorOnSurface</color>
<color name="signal_colorOnSurfaceVariant">@color/signal_dark_colorOnSurfaceVariant</color>
<color name="signal_colorOnSurfaceVariant1">@color/signal_dark_colorOnSurfaceVariant1</color>
<color name="signal_colorOnBackground">@color/signal_dark_colorOnBackground</color>
<color name="signal_colorOutline">@color/signal_dark_colorOutline</color>
<color name="signal_neutralSurface">@color/signal_dark_neutralSurface</color>

View file

@ -16,6 +16,7 @@
<color name="signal_colorOnSecondaryContainer">@color/signal_light_colorOnSecondaryContainer</color>
<color name="signal_colorOnSurface">@color/signal_light_colorOnSurface</color>
<color name="signal_colorOnSurfaceVariant">@color/signal_light_colorOnSurfaceVariant</color>
<color name="signal_colorOnSurfaceVariant1">@color/signal_light_colorOnSurfaceVariant1</color>
<color name="signal_colorOnBackground">@color/signal_light_colorOnBackground</color>
<color name="signal_colorOutline">@color/signal_light_colorOutline</color>
<color name="signal_neutralSurface">@color/signal_light_neutralSurface</color>

View file

@ -15,6 +15,7 @@
<color name="signal_dark_colorOnSecondaryContainer">#DCE1F9</color>
<color name="signal_dark_colorOnSurface">#E2E1E5</color>
<color name="signal_dark_colorOnSurfaceVariant">#BEBFC5</color>
<color name="signal_dark_colorOnSurfaceVariant1">#4D5059</color>
<color name="signal_dark_colorOnBackground">#E2E1E5</color>
<color name="signal_dark_colorOutline">#5C5E65</color>
<color name="signal_dark_neutralSurface">#14FFFFFF</color>

View file

@ -15,6 +15,7 @@
<color name="signal_light_colorOnSecondaryContainer">#151D2C</color>
<color name="signal_light_colorOnSurface">#1B1B1D</color>
<color name="signal_light_colorOnSurfaceVariant">#545863</color>
<color name="signal_light_colorOnSurfaceVariant1">#BBBFC8</color>
<color name="signal_light_colorOnBackground">#1B1D1D</color>
<color name="signal_light_colorOutline">#808389</color>
<color name="signal_light_neutralSurface">#99FFFFFF</color>

View file

@ -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 }),

View file

@ -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 = "<br>Type: ${if (mention) "mention" else "style"}<br>-start: $start<br>-length: $length"
if (mention) {
rangeString += "<br>-uuid: ${range.mentionUuid}"
}
if (style) {
rangeString += "<br>-style: ${range.style}"
}
rangeString
}.joinToString("<br>")
} else {
null
}
}
}