Add ability to clear or toggle formatting.

This commit is contained in:
Cody Henthorne
2023-06-21 13:05:46 -04:00
committed by GitHub
parent 59b2cc5f79
commit 1d0a87f52a
8 changed files with 554 additions and 116 deletions

View File

@@ -284,15 +284,11 @@ public class ComposeText extends EmojiEditText {
public boolean hasStyling() {
CharSequence trimmed = getTextTrimmed();
return FeatureFlags.textFormatting() && (trimmed instanceof Spanned) && MessageStyler.hasStyling((Spanned) trimmed);
return (trimmed instanceof Spanned) && MessageStyler.hasStyling((Spanned) trimmed);
}
public @Nullable BodyRangeList getStyling() {
if (FeatureFlags.textFormatting()) {
return MessageStyler.getStyling(getTextTrimmed());
} else {
return null;
}
return MessageStyler.getStyling(getTextTrimmed());
}
private void initialize() {
@@ -306,87 +302,94 @@ public class ComposeText extends EmojiEditText {
mentionValidatorWatcher = new MentionValidatorWatcher();
addTextChangedListener(mentionValidatorWatcher);
if (FeatureFlags.textFormatting()) {
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
addTextChangedListener(new ComposeTextStyleWatcher());
addTextChangedListener(new ComposeTextStyleWatcher());
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuItem copy = menu.findItem(android.R.id.copy);
MenuItem cut = menu.findItem(android.R.id.cut);
MenuItem paste = menu.findItem(android.R.id.paste);
int copyOrder = copy != null ? copy.getOrder() : 0;
int cutOrder = cut != null ? cut.getOrder() : 0;
int pasteOrder = paste != null ? paste.getOrder() : 0;
int largestOrder = Math.max(copyOrder, Math.max(cutOrder, pasteOrder));
setCustomSelectionActionModeCallback(new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuItem copy = menu.findItem(android.R.id.copy);
MenuItem cut = menu.findItem(android.R.id.cut);
MenuItem paste = menu.findItem(android.R.id.paste);
int copyOrder = copy != null ? copy.getOrder() : 0;
int cutOrder = cut != null ? cut.getOrder() : 0;
int pasteOrder = paste != null ? paste.getOrder() : 0;
int largestOrder = Math.max(copyOrder, Math.max(cutOrder, pasteOrder));
menu.add(0, R.id.edittext_bold, largestOrder, getContext().getString(R.string.TextFormatting_bold));
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));
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
menu.add(0, R.id.edittext_bold, largestOrder, getContext().getString(R.string.TextFormatting_bold));
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));
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
return true;
Editable text = getText();
if (text != null) {
int start = getSelectionStart();
int end = getSelectionEnd();
if (MessageStyler.hasStyling(text, start, end)) {
menu.add(0, R.id.edittext_clear_formatting, largestOrder, getContext().getString(R.string.TextFormatting_clear_formatting));
}
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
Editable text = getText();
return true;
}
if (text == null) {
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
Editable text = getText();
if (item.getItemId() != R.id.edittext_bold &&
item.getItemId() != R.id.edittext_italic &&
item.getItemId() != R.id.edittext_strikethrough &&
item.getItemId() != R.id.edittext_monospace &&
item.getItemId() != R.id.edittext_spoiler) {
return false;
}
int start = getSelectionStart();
int end = getSelectionEnd();
CharSequence charSequence = text.subSequence(start, end);
SpannableString replacement = new SpannableString(charSequence);
Object style = null;
if (item.getItemId() == R.id.edittext_bold) {
style = MessageStyler.boldStyle();
} else if (item.getItemId() == R.id.edittext_italic) {
style = MessageStyler.italicStyle();
} else if (item.getItemId() == R.id.edittext_strikethrough) {
style = MessageStyler.strikethroughStyle();
} else if (item.getItemId() == R.id.edittext_monospace) {
style = MessageStyler.monoStyle();
} else if (item.getItemId() == R.id.edittext_spoiler) {
style = MessageStyler.spoilerStyle(MessageStyler.COMPOSE_ID, start, charSequence.length());
}
if (style != null) {
replacement.setSpan(style, 0, charSequence.length(), MessageStyler.SPAN_FLAGS);
}
clearComposingText();
text.replace(start, end, replacement);
mode.finish();
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
if (text == null) {
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {}
});
}
if (item.getItemId() != R.id.edittext_bold &&
item.getItemId() != R.id.edittext_italic &&
item.getItemId() != R.id.edittext_strikethrough &&
item.getItemId() != R.id.edittext_monospace &&
item.getItemId() != R.id.edittext_spoiler &&
item.getItemId() != R.id.edittext_clear_formatting)
{
return false;
}
int start = getSelectionStart();
int end = getSelectionEnd();
BodyRangeList.BodyRange.Style style = null;
if (item.getItemId() == R.id.edittext_bold) {
style = BodyRangeList.BodyRange.Style.BOLD;
} else if (item.getItemId() == R.id.edittext_italic) {
style = BodyRangeList.BodyRange.Style.ITALIC;
} else if (item.getItemId() == R.id.edittext_strikethrough) {
style = BodyRangeList.BodyRange.Style.STRIKETHROUGH;
} else if (item.getItemId() == R.id.edittext_monospace) {
style = BodyRangeList.BodyRange.Style.MONOSPACE;
} else if (item.getItemId() == R.id.edittext_spoiler) {
style = BodyRangeList.BodyRange.Style.SPOILER;
}
clearComposingText();
if (style != null) {
MessageStyler.toggleStyle(style, text, start, end);
} else if (item.getItemId() == R.id.edittext_clear_formatting) {
MessageStyler.clearStyling(text, start, end);
}
mode.finish();
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {}
});
}
private void setHintWithChecks(@Nullable CharSequence newHint) {

View File

@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.conversation.MessageStyler.isSupportedStyle
/**
* Formatting should only grow when appending until a white space character is entered/pasted.
*
* This watcher observes changes to the text and will grow supported style ranges as necessary
* This watcher observes changes to the text and will shrink supported style ranges as necessary
* to provide the desired behavior.
*/
class ComposeTextStyleWatcher : TextWatcher {
@@ -45,29 +45,32 @@ class ComposeTextStyleWatcher : TextWatcher {
s.removeSpan(markerAnnotation)
try {
if (editStart <= 0 || editEnd < 0 || editStart >= editEnd) {
if (editStart < 0 || editEnd < 0 || editStart >= editEnd || (editStart == 0 && editEnd == s.length)) {
return
}
val change = s.subSequence(editStart, editEnd)
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
if (change.isEmpty() || textSnapshotPriorToChange == null || (editEnd - editStart == 1 && !StringUtil.isVisuallyEmpty(change[0])) || TextUtils.equals(textSnapshotPriorToChange, change)) {
textSnapshotPriorToChange = null
return
}
textSnapshotPriorToChange = null
var newEnd = editStart
for (i in change.indices) {
if (!StringUtil.isVisuallyEmpty(change[i])) {
newEnd++
if (StringUtil.isVisuallyEmpty(change[i])) {
newEnd = editStart + i
break
}
}
s.getSpans(editStart - 1, editStart, Object::class.java)
s.getSpans(editStart, editEnd, Object::class.java)
.filter { it.isSupportedStyle() }
.forEach { style ->
val styleStart = s.getSpanStart(style)
val styleEnd = s.getSpanEnd(style)
if (styleEnd == editStart && styleStart < styleEnd) {
if (styleEnd == editEnd && styleStart < styleEnd) {
s.removeSpan(style)
s.setSpan(style, styleStart, newEnd, MessageStyler.SPAN_FLAGS)
} else if (styleStart >= styleEnd) {
@@ -75,7 +78,6 @@ class ComposeTextStyleWatcher : TextWatcher {
}
}
} finally {
textSnapshotPriorToChange = null
s.getSpans(editStart, editEnd, Object::class.java)
.filter { it.isSupportedStyle() }
.forEach { style ->

View File

@@ -10,7 +10,10 @@ import android.text.style.StyleSpan
import android.text.style.TypefaceSpan
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList.BodyRange
import org.thoughtcrime.securesms.util.PlaceholderURLSpan
import java.lang.Integer.max
import java.lang.Integer.min
/**
* Helper for parsing and applying styles. Most notably with [BodyRangeList].
@@ -18,7 +21,7 @@ import org.thoughtcrime.securesms.util.PlaceholderURLSpan
object MessageStyler {
const val MONOSPACE = "monospace"
const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_INCLUSIVE
const val DRAFT_ID = "DRAFT"
const val COMPOSE_ID = "COMPOSE"
const val QUOTE_ID = "QUOTE"
@@ -57,7 +60,7 @@ object MessageStyler {
var appliedStyle = false
var hasLinks = false
var bottomButton: BodyRangeList.BodyRange.Button? = null
var bottomButton: BodyRange.Button? = null
messageRanges
.rangesList
@@ -68,17 +71,18 @@ object MessageStyler {
if (range.hasStyle()) {
val styleSpan: Any? = when (range.style) {
BodyRangeList.BodyRange.Style.BOLD -> boldStyle()
BodyRangeList.BodyRange.Style.ITALIC -> italicStyle()
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRangeList.BodyRange.Style.MONOSPACE -> monoStyle()
BodyRangeList.BodyRange.Style.SPOILER -> {
BodyRange.Style.BOLD -> boldStyle()
BodyRange.Style.ITALIC -> italicStyle()
BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRange.Style.MONOSPACE -> monoStyle()
BodyRange.Style.SPOILER -> {
val spoiler = spoilerStyle(id, range.start, range.length)
if (hideSpoilerText) {
span.setSpan(SpoilerAnnotation.SpoilerClickableSpan(spoiler), start, end, SPAN_FLAGS)
}
spoiler
}
else -> null
}
@@ -102,9 +106,111 @@ object MessageStyler {
}
@JvmStatic
fun hasStyling(text: Spanned): Boolean {
fun toggleStyle(style: BodyRange.Style, text: Spannable, start: Int, end: Int) {
if (start >= end) {
return
}
val toggleRange = start..end
val spanAndRanges = text
.getSpans(start, end, Object::class.java)
.asSequence()
.filter { it.isStyle(style) }
.map { SpanAndRange(it, text.getSpanStart(it)..text.getSpanEnd(it)) }
.toMutableList()
val isForceAdditive = spanAndRanges.hasGapsBetween(toggleRange)
var shouldAddRange = true
val iterator = spanAndRanges.iterator()
while (iterator.hasNext()) {
val existingStyle = iterator.next()
if (toggleRange == existingStyle.range) {
text.removeSpan(existingStyle.span)
iterator.remove()
shouldAddRange = false
} else if (toggleRange.containedIn(existingStyle.range)) {
text.removeSpan(existingStyle.span)
iterator.remove()
text.setSpan(style.toStyleSpan(existingStyle.range.first, toggleRange.first), existingStyle.range.first, toggleRange.first, SPAN_FLAGS)
text.setSpan(style.toStyleSpan(toggleRange.last, existingStyle.range.last), toggleRange.last, existingStyle.range.last, SPAN_FLAGS)
shouldAddRange = false
break
} else if (toggleRange.covers(existingStyle.range) && isForceAdditive) {
text.removeSpan(existingStyle.span)
iterator.remove()
}
}
if (shouldAddRange) {
val styleSpan = style.toStyleSpan(start, end - start)
text.setSpan(styleSpan, start, end, SPAN_FLAGS)
spanAndRanges += SpanAndRange(styleSpan, start..end)
}
spanAndRanges.sortWith { (_, lhs), (_, rhs) ->
val compareStart = lhs.first.compareTo(rhs.first)
if (compareStart == 0) {
lhs.last.compareTo(rhs.last)
} else {
compareStart
}
}
var index = 0
while (index < spanAndRanges.size) {
val spanAndRange = spanAndRanges[index]
val nextSpanAndRange = if (index < spanAndRanges.lastIndex) spanAndRanges[index + 1] else null
if (spanAndRange.range.first == spanAndRange.range.last) {
text.removeSpan(spanAndRange.span)
spanAndRanges.removeAt(index)
} else if (nextSpanAndRange != null && spanAndRange.range.overlapsStart(nextSpanAndRange.range)) {
text.removeSpan(nextSpanAndRange.span)
spanAndRanges.removeAt(index + 1)
text.removeSpan(spanAndRange.span)
spanAndRanges.removeAt(index)
val mergedRange = min(nextSpanAndRange.range.first, spanAndRange.range.first)..max(nextSpanAndRange.range.last, spanAndRange.range.last)
val styleSpan = style.toStyleSpan(mergedRange.first, mergedRange.last - mergedRange.first)
text.setSpan(styleSpan, mergedRange.first, mergedRange.last, SPAN_FLAGS)
spanAndRanges.add(index, SpanAndRange(styleSpan, mergedRange))
} else {
index++
}
}
}
@JvmStatic
fun clearStyling(text: Spannable, start: Int, end: Int) {
val clearRange = start..end
text
.getSpans(start, end, Object::class.java)
.asSequence()
.filter { it.isSupportedStyle() }
.map { SpanAndRange(it, text.getSpanStart(it)..text.getSpanEnd(it)) }
.forEach { spanAndRange ->
if (clearRange.covers(spanAndRange.range)) {
text.removeSpan(spanAndRange.span)
} else if (clearRange.containedIn(spanAndRange.range)) {
text.removeSpan(spanAndRange.span)
text.setSpan(copyStyleSpan(spanAndRange.span, spanAndRange.range.first, clearRange.first), spanAndRange.range.first, clearRange.first, SPAN_FLAGS)
text.setSpan(copyStyleSpan(spanAndRange.span, clearRange.last, spanAndRange.range.last - clearRange.last), clearRange.last, spanAndRange.range.last, SPAN_FLAGS)
} else if (clearRange.overlapsStart(spanAndRange.range)) {
text.removeSpan(spanAndRange.span)
text.setSpan(copyStyleSpan(spanAndRange.span, clearRange.last, spanAndRange.range.last), clearRange.last, spanAndRange.range.last, SPAN_FLAGS)
} else if (clearRange.overlapsEnd(spanAndRange.range)) {
text.removeSpan(spanAndRange.span)
text.setSpan(copyStyleSpan(spanAndRange.span, spanAndRange.range.first, clearRange.first), spanAndRange.range.first, clearRange.first, SPAN_FLAGS)
}
}
}
@JvmStatic
@JvmOverloads
fun hasStyling(text: Spanned, start: Int = 0, end: Int = text.length): Boolean {
return text
.getSpans(0, text.length, Object::class.java)
.getSpans(start, end, Object::class.java)
.any { s -> s.isSupportedStyle() && text.getSpanEnd(s) - text.getSpanStart(s) > 0 }
}
@@ -118,19 +224,19 @@ object MessageStyler {
val spanStart = text.getSpanStart(span)
val spanLength = text.getSpanEnd(span) - spanStart
val style: BodyRangeList.BodyRange.Style? = when (span) {
val style: BodyRange.Style? = when (span) {
is StyleSpan -> {
when (span.style) {
Typeface.BOLD -> BodyRangeList.BodyRange.Style.BOLD
Typeface.ITALIC -> BodyRangeList.BodyRange.Style.ITALIC
Typeface.BOLD -> BodyRange.Style.BOLD
Typeface.ITALIC -> BodyRange.Style.ITALIC
else -> null
}
}
is StrikethroughSpan -> BodyRangeList.BodyRange.Style.STRIKETHROUGH
is TypefaceSpan -> BodyRangeList.BodyRange.Style.MONOSPACE
is StrikethroughSpan -> BodyRange.Style.STRIKETHROUGH
is TypefaceSpan -> BodyRange.Style.MONOSPACE
is Annotation -> {
if (SpoilerAnnotation.isSpoilerAnnotation(span)) {
BodyRangeList.BodyRange.Style.SPOILER
BodyRange.Style.SPOILER
} else {
null
}
@@ -139,7 +245,7 @@ object MessageStyler {
}
if (spanLength > 0 && style != null) {
BodyRangeList.BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build()
} else {
null
}
@@ -157,22 +263,72 @@ object MessageStyler {
fun Any.isSupportedStyle(): Boolean {
return when (this) {
is CharacterStyle -> isSupportedCharacterStyle(this)
is CharacterStyle -> isSupportedCharacterStyle()
is Annotation -> SpoilerAnnotation.isSpoilerAnnotation(this)
else -> false
}
}
private fun isSupportedCharacterStyle(style: CharacterStyle): Boolean {
return when (style) {
is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD
private fun Any.isSupportedCharacterStyle(): Boolean {
return when (this) {
is StyleSpan -> style == Typeface.ITALIC || style == Typeface.BOLD
is StrikethroughSpan -> true
is TypefaceSpan -> style.family == MONOSPACE
is TypefaceSpan -> family == MONOSPACE
else -> false
}
}
data class Result(val hasStyleLinks: Boolean = false, val bottomButton: BodyRangeList.BodyRange.Button? = null) {
private fun Any.isStyle(style: BodyRange.Style): Boolean {
return when (this) {
is CharacterStyle -> isCharacterStyle(style)
is Annotation -> SpoilerAnnotation.isSpoilerAnnotation(this) && style == BodyRange.Style.SPOILER
else -> false
}
}
private fun CharacterStyle.isCharacterStyle(style: BodyRange.Style): Boolean {
return when (this) {
is StyleSpan -> (this.style == Typeface.ITALIC && style == BodyRange.Style.ITALIC) || (this.style == Typeface.BOLD && style == BodyRange.Style.BOLD)
is StrikethroughSpan -> style == BodyRange.Style.STRIKETHROUGH
is TypefaceSpan -> this.family == MONOSPACE && style == BodyRange.Style.MONOSPACE
else -> false
}
}
private fun copyStyleSpan(span: Any, start: Int, length: Int): Any? {
return when (span) {
is StyleSpan -> {
when (span.style) {
Typeface.BOLD -> boldStyle()
Typeface.ITALIC -> italicStyle()
else -> null
}
}
is StrikethroughSpan -> strikethroughStyle()
is TypefaceSpan -> monoStyle()
is Annotation -> {
if (SpoilerAnnotation.isSpoilerAnnotation(span)) {
spoilerStyle(COMPOSE_ID, start, length)
} else {
null
}
}
else -> throw IllegalArgumentException("Provided text contains unsupported spans")
}
}
private fun BodyRange.Style.toStyleSpan(start: Int, length: Int): Any {
return when (this) {
BodyRange.Style.BOLD -> boldStyle()
BodyRange.Style.ITALIC -> italicStyle()
BodyRange.Style.SPOILER -> spoilerStyle(COMPOSE_ID, start, length)
BodyRange.Style.STRIKETHROUGH -> strikethroughStyle()
BodyRange.Style.MONOSPACE -> monoStyle()
else -> throw IllegalArgumentException()
}
}
data class Result(val hasStyleLinks: Boolean = false, val bottomButton: BodyRange.Button? = null) {
companion object {
@JvmStatic
val NO_STYLE = Result()
@@ -181,4 +337,49 @@ object MessageStyler {
fun none(): Result = NO_STYLE
}
}
private data class SpanAndRange(val span: Any, val range: IntRange)
private fun IntRange.overlapsStart(other: IntRange): Boolean {
return this.first <= other.first && this.last > other.first
}
private fun IntRange.overlapsEnd(other: IntRange): Boolean {
return this.first < other.last && this.last >= other.last
}
private fun IntRange.containedIn(other: IntRange): Boolean {
return this.first >= other.first && this.last <= other.last
}
private fun IntRange.covers(other: IntRange): Boolean {
return this.first <= other.first && this.last >= other.last
}
/**
* Checks if a sorted, non-overlapping list of ranges does not cover the provided [toggleRange] completely. That is,
* there is a value that does not exists in any of the ranges in the list but does exist in [toggleRange]
*
* For example, a list of ranges [[0..5], [7..10]] and a toggle range of [0..10] has a gap for the value 6. If
* the list was [[0..5], [6..8], [9..10]] there would be no gaps.
*/
private fun List<SpanAndRange>.hasGapsBetween(toggleRange: IntRange): Boolean {
val startingRangeIndex = indexOfFirst { it.range.first <= toggleRange.first && it.range.last >= toggleRange.first }
if (startingRangeIndex == -1) {
return true
}
val endingRangeIndex = indexOfFirst { it.range.first <= toggleRange.last && it.range.last >= toggleRange.last }
if (endingRangeIndex == -1) {
return true
}
for (i in startingRangeIndex until endingRangeIndex) {
if (this[i].range.last != this[i + 1].range.first) {
return true
}
}
return false
}
}

View File

@@ -102,7 +102,6 @@ public final class FeatureFlags {
private static final String CHAT_FILTERS = "android.chat.filters.3";
private static final String PAYPAL_ONE_TIME_DONATIONS = "android.oneTimePayPalDonations.2";
private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.3";
private static final String TEXT_FORMATTING = "android.textFormatting.3";
private static final String ANY_ADDRESS_PORTS_KILL_SWITCH = "android.calling.fieldTrial.anyAddressPortsKillSwitch";
private static final String AD_HOC_CALLING = "android.calling.ad.hoc.2";
private static final String EDIT_MESSAGE_SEND = "android.editMessage.send.2";
@@ -163,7 +162,6 @@ public final class FeatureFlags {
CHAT_FILTERS,
PAYPAL_ONE_TIME_DONATIONS,
PAYPAL_RECURRING_DONATIONS,
TEXT_FORMATTING,
ANY_ADDRESS_PORTS_KILL_SWITCH,
EDIT_MESSAGE_SEND,
MAX_ATTACHMENT_COUNT,
@@ -231,7 +229,6 @@ public final class FeatureFlags {
CREDIT_CARD_PAYMENTS,
PAYMENTS_REQUEST_ACTIVATE_FLOW,
CDS_HARD_LIMIT,
TEXT_FORMATTING,
EDIT_MESSAGE_SEND,
MAX_ATTACHMENT_COUNT,
MAX_ATTACHMENT_SIZE_MB
@@ -574,13 +571,6 @@ public final class FeatureFlags {
return getBoolean(PAYPAL_RECURRING_DONATIONS, Environment.IS_STAGING);
}
/**
* Whether or not we should show text formatting options.
*/
public static boolean textFormatting() {
return getBoolean(TEXT_FORMATTING, false);
}
/**
* Enable/disable RingRTC field trial for "AnyAddressPortsKillSwitch"
*/

View File

@@ -31,4 +31,5 @@
<item name="edittext_strikethrough" type="id" />
<item name="edittext_monospace" type="id" />
<item name="edittext_spoiler" type="id" />
<item name="edittext_clear_formatting" type="id" />
</resources>

View File

@@ -5920,6 +5920,8 @@
<string name="TextFormatting_monospace">Monospace</string>
<!-- Popup menu label for applying spoiler style -->
<string name="TextFormatting_spoiler">Spoiler</string>
<!-- Popup menu label for clearing applied formatting -->
<string name="TextFormatting_clear_formatting">Clear formatting</string>
<!-- UsernameEducationFragment -->
<!-- Continue button which takes the user to the add a username screen -->

View File

@@ -0,0 +1,210 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import android.app.Application
import android.text.Spannable
import android.text.SpannableString
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.assertIs
import org.thoughtcrime.securesms.assertIsNull
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList.BodyRange.Style
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class MessageStylerTest {
private lateinit var text: Spannable
@Before
fun setUp() {
text = SpannableString("This is a really long string for testing. Also thank you to all our beta testers!")
}
@Test
fun nonOverlappingDifferentStyles() {
MessageStyler.toggleStyle(Style.BOLD, text, 0, 5)
MessageStyler.toggleStyle(Style.ITALIC, text, 10, 15)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 2
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 5
}
bodyRange.rangesList[1].apply {
style assertIs Style.ITALIC
start assertIs 10
length assertIs 5
}
}
@Test
fun overlappingDifferentStyles() {
MessageStyler.toggleStyle(Style.BOLD, text, 0, 5)
MessageStyler.toggleStyle(Style.ITALIC, text, 3, 10)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 2
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 5
}
bodyRange.rangesList[1].apply {
style assertIs Style.ITALIC
start assertIs 3
length assertIs 7
}
}
@Test
fun overlappingBeginning() {
MessageStyler.toggleStyle(Style.BOLD, text, 3, 10)
MessageStyler.toggleStyle(Style.BOLD, text, 0, 5)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 1
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 10
}
}
@Test
fun overlappingEnd() {
MessageStyler.toggleStyle(Style.BOLD, text, 0, 5)
MessageStyler.toggleStyle(Style.BOLD, text, 3, 10)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 1
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 10
}
}
@Test
fun overlappingContained() {
MessageStyler.toggleStyle(Style.BOLD, text, 0, 10)
MessageStyler.toggleStyle(Style.BOLD, text, 4, 6)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 2
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 4
}
bodyRange.rangesList[1].apply {
style assertIs Style.BOLD
start assertIs 6
length assertIs 4
}
}
@Test
fun overlappingCovering() {
MessageStyler.toggleStyle(Style.BOLD, text, 4, 6)
MessageStyler.toggleStyle(Style.BOLD, text, 0, 10)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 1
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 10
}
}
@Test
fun overlappingExact() {
MessageStyler.toggleStyle(Style.BOLD, text, 4, 6)
MessageStyler.toggleStyle(Style.BOLD, text, 4, 6)
val bodyRange = MessageStyler.getStyling(text)
bodyRange.assertIsNull()
}
@Test
fun overlappingCoveringMultiple() {
MessageStyler.toggleStyle(Style.BOLD, text, 0, 3)
MessageStyler.toggleStyle(Style.BOLD, text, 6, 8)
MessageStyler.toggleStyle(Style.BOLD, text, 0, 10)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 1
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 10
}
}
@Test
fun overlappingEndAndBeginning() {
MessageStyler.toggleStyle(Style.BOLD, text, 0, 3)
MessageStyler.toggleStyle(Style.BOLD, text, 6, 8)
MessageStyler.toggleStyle(Style.BOLD, text, 2, 7)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 1
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 8
}
}
@Test
fun clearFormatting() {
MessageStyler.toggleStyle(Style.BOLD, text, 0, 10)
MessageStyler.clearStyling(text, 3, 7)
val bodyRange = MessageStyler.getStyling(text)!!
bodyRange.rangesCount assertIs 2
bodyRange.rangesList[0].apply {
style assertIs Style.BOLD
start assertIs 0
length assertIs 3
}
bodyRange.rangesList[1].apply {
style assertIs Style.BOLD
start assertIs 7
length assertIs 3
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms
import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
fun <T : Any?> T.assertIsNull() {
MatcherAssert.assertThat(this, Matchers.nullValue())
}
fun <T : Any?> T.assertIsNotNull() {
MatcherAssert.assertThat(this, Matchers.notNullValue())
}
infix fun <T : Any?> T.assertIs(expected: T) {
MatcherAssert.assertThat(this, Matchers.`is`(expected))
}
infix fun <T : Any> T.assertIsNot(expected: T) {
MatcherAssert.assertThat(this, Matchers.not(Matchers.`is`(expected)))
}
infix fun <E, T : Collection<E>> T.assertIsSize(expected: Int) {
MatcherAssert.assertThat(this, Matchers.hasSize(expected))
}