From 1d0a87f52ae3ebc4abb23db28f0d035644d91176 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 21 Jun 2023 13:05:46 -0400 Subject: [PATCH] Add ability to clear or toggle formatting. --- .../securesms/components/ComposeText.java | 155 +++++------ .../components/ComposeTextStyleWatcher.kt | 18 +- .../securesms/conversation/MessageStyler.kt | 245 ++++++++++++++++-- .../securesms/util/FeatureFlags.java | 10 - app/src/main/res/values/ids.xml | 1 + app/src/main/res/values/strings.xml | 2 + .../conversation/MessageStylerTest.kt | 210 +++++++++++++++ .../securesms/KotlinAssertsUtil.kt | 29 +++ 8 files changed, 554 insertions(+), 116 deletions(-) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/conversation/MessageStylerTest.kt create mode 100644 app/src/testShared/org/thoughtcrime/securesms/KotlinAssertsUtil.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 5caf78e7bc..229dada167 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt index 35038338a6..2de670ac40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt @@ -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 -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt index f0011028ff..6ff76204d7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt @@ -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.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 + } } 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 c3ffafb301..c24328623f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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" */ diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 532b9cf2b2..92fcde7cbc 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -31,4 +31,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6325aaa347..48e59d9ef3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5920,6 +5920,8 @@ Monospace Spoiler + + Clear formatting diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/MessageStylerTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/MessageStylerTest.kt new file mode 100644 index 0000000000..40474af30d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/MessageStylerTest.kt @@ -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 + } + } +} diff --git a/app/src/testShared/org/thoughtcrime/securesms/KotlinAssertsUtil.kt b/app/src/testShared/org/thoughtcrime/securesms/KotlinAssertsUtil.kt new file mode 100644 index 0000000000..e091d9ac02 --- /dev/null +++ b/app/src/testShared/org/thoughtcrime/securesms/KotlinAssertsUtil.kt @@ -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.assertIsNull() { + MatcherAssert.assertThat(this, Matchers.nullValue()) +} + +fun T.assertIsNotNull() { + MatcherAssert.assertThat(this, Matchers.notNullValue()) +} + +infix fun T.assertIs(expected: T) { + MatcherAssert.assertThat(this, Matchers.`is`(expected)) +} + +infix fun T.assertIsNot(expected: T) { + MatcherAssert.assertThat(this, Matchers.not(Matchers.`is`(expected))) +} + +infix fun > T.assertIsSize(expected: Int) { + MatcherAssert.assertThat(this, Matchers.hasSize(expected)) +}