From ee48e6c347cb9753dd467d573a4fb8f6db5b7b59 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Tue, 21 Mar 2023 11:43:43 -0400 Subject: [PATCH] Add sync message handling and stop formatting behavior. --- .../securesms/components/ComposeText.java | 4 +- .../components/ComposeTextStyleWatcher.kt | 80 +++++++++++++++++++ .../securesms/conversation/MessageStyler.kt | 4 +- .../messages/MessageContentProcessor.java | 33 ++++---- 4 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.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 171f199504..57d5751d80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -298,6 +298,8 @@ public class ComposeText extends EmojiEditText { addTextChangedListener(mentionValidatorWatcher); if (FeatureFlags.textFormatting()) { + addTextChangedListener(new ComposeTextStyleWatcher()); + setCustomSelectionActionModeCallback(new ActionMode.Callback() { @Override public boolean onCreateActionMode(ActionMode mode, Menu menu) { @@ -350,7 +352,7 @@ public class ComposeText extends EmojiEditText { } if (style != null) { - replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + replacement.setSpan(style, 0, charSequence.length(), MessageStyler.SPAN_FLAGS); } clearComposingText(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt new file mode 100644 index 0000000000..d904c0b6ec --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeTextStyleWatcher.kt @@ -0,0 +1,80 @@ +package org.thoughtcrime.securesms.components + +import android.text.Annotation +import android.text.Editable +import android.text.Spannable +import android.text.Spanned +import android.text.TextUtils +import android.text.TextWatcher +import android.text.style.CharacterStyle +import org.signal.core.util.StringUtil +import org.thoughtcrime.securesms.conversation.MessageStyler + +/** + * Formatting should only grow when appending until a white space character is entered/pasted. + * + * This watcher observes changes to the text and will shrink supported style ranges as necessary + * to provide the desired behavior. + */ +class ComposeTextStyleWatcher : TextWatcher { + private val markerAnnotation = Annotation("text-formatting", "marker") + private var textSnapshotPriorToChange: CharSequence? = null + + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + if (s is Spannable) { + s.removeSpan(markerAnnotation) + } + + textSnapshotPriorToChange = s.subSequence(start, start + count) + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (s is Spannable) { + s.removeSpan(markerAnnotation) + + if (count > 0) { + s.setSpan(markerAnnotation, start, start + count, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + } + + override fun afterTextChanged(s: Editable) { + val editStart = s.getSpanStart(markerAnnotation) + val editEnd = s.getSpanEnd(markerAnnotation) + + s.removeSpan(markerAnnotation) + + 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)) { + textSnapshotPriorToChange = null + return + } + textSnapshotPriorToChange = null + + var newEnd = editStart + for (i in change.indices) { + if (StringUtil.isVisuallyEmpty(change[i])) { + newEnd = editStart + i + break + } + } + + s.getSpans(editStart, editEnd, CharacterStyle::class.java) + .filter { MessageStyler.isSupportedCharacterStyle(it) } + .forEach { style -> + val styleStart = s.getSpanStart(style) + val styleEnd = s.getSpanEnd(style) + + if (styleEnd == editEnd && styleStart < styleEnd) { + s.removeSpan(style) + s.setSpan(style, styleStart, newEnd, MessageStyler.SPAN_FLAGS) + } else { + s.removeSpan(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 3bc866e17d..a7e059b279 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt @@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.util.PlaceholderURLSpan object MessageStyler { const val MONOSPACE = "monospace" + const val SPAN_FLAGS = Spanned.SPAN_EXCLUSIVE_INCLUSIVE @JvmStatic fun boldStyle(): CharacterStyle { @@ -61,7 +62,7 @@ object MessageStyler { } if (styleSpan != null) { - span.setSpan(styleSpan, range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + span.setSpan(styleSpan, range.start, range.start + range.length, SPAN_FLAGS) appliedStyle = true } } else if (range.hasLink() && range.link != null) { @@ -121,6 +122,7 @@ object MessageStyler { } } + @JvmStatic fun isSupportedCharacterStyle(style: CharacterStyle): Boolean { return when (style) { is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index 5f6a629e31..2033eb1899 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -1347,7 +1347,7 @@ public class MessageContentProcessor { threadId = SignalDatabase.threads().getOrCreateThreadIdFor(getSyncMessageDestination(message)); } else if (dataMessage.getRemoteDelete().isPresent()) { handleRemoteDelete(content, dataMessage, senderRecipient, processingEarlyContent); - } else if (dataMessage.getAttachments().isPresent() || dataMessage.getQuote().isPresent() || dataMessage.getPreviews().isPresent() || dataMessage.getSticker().isPresent() || dataMessage.isViewOnce() || dataMessage.getMentions().isPresent()) { + } else if (dataMessage.getAttachments().isPresent() || dataMessage.getQuote().isPresent() || dataMessage.getPreviews().isPresent() || dataMessage.getSticker().isPresent() || dataMessage.isViewOnce() || dataMessage.getMentions().isPresent() || dataMessage.getBodyRanges().isPresent()) { threadId = handleSynchronizeSentMediaMessage(message, content.getTimestamp()); } else { threadId = handleSynchronizeSentTextMessage(message, content.getTimestamp()); @@ -2094,12 +2094,14 @@ public class MessageContentProcessor { MmsMessageRecord story = (MmsMessageRecord) database.getMessageRecord(storyMessageId.getId()); Recipient threadRecipient = SignalDatabase.threads().getRecipientForThreadId(story.getThreadId()); boolean groupStory = threadRecipient != null && threadRecipient.isActiveGroup(); + BodyRangeList bodyRanges = null; String body; if (reaction.isPresent() && EmojiUtil.isEmoji(reaction.get().getEmoji())) { body = reaction.get().getEmoji(); } else { - body = message.getDataMessage().get().getBody().orElse(null); + body = message.getDataMessage().get().getBody().orElse(null); + bodyRanges = getBodyRangeList(message.getDataMessage().get().getBodyRanges()); } if (message.getDataMessage().get().getGroupContext().isPresent()) { @@ -2107,15 +2109,15 @@ public class MessageContentProcessor { } else if (groupStory || story.getStoryType().isStoryWithReplies()) { parentStoryId = new ParentStoryId.DirectReply(storyMessageId.getId()); - String quoteBody = ""; - BodyRangeList bodyRanges = null; + String quoteBody = ""; + BodyRangeList quotedBodyRanges = null; if (story.getStoryType().isTextStory()) { - quoteBody = story.getBody(); - bodyRanges = story.getMessageRanges(); + quoteBody = story.getBody(); + quotedBodyRanges = story.getMessageRanges(); } - quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, quoteBody, false, story.getSlideDeck().asAttachments(), Collections.emptyList(), QuoteModel.Type.NORMAL, bodyRanges); + quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, quoteBody, false, story.getSlideDeck().asAttachments(), Collections.emptyList(), QuoteModel.Type.NORMAL, quotedBodyRanges); expiresInMillis = TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds()); } else { warn(envelopeTimestamp, "Story has replies disabled. Dropping reply."); @@ -2141,7 +2143,7 @@ public class MessageContentProcessor { Collections.emptySet(), null, true, - null, + bodyRanges, -1); if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { @@ -2205,6 +2207,7 @@ public class MessageContentProcessor { Set distributionIds = manifest.getDistributionIdSet(); Optional groupId = storyMessage.getGroupContext().map(it -> GroupId.v2(it.getMasterKey())); String textStoryBody = storyMessage.getTextAttachment().map(this::serializeTextAttachment).orElse(null); + BodyRangeList bodyRanges = getBodyRangeList(storyMessage.getBodyRanges()); StoryType storyType = getStoryType(storyMessage); List linkPreviews = getLinkPreviews(storyMessage.getTextAttachment().flatMap(t -> t.getPreview().map(Collections::singletonList)), "", @@ -2216,13 +2219,13 @@ public class MessageContentProcessor { for (final DistributionId distributionId : distributionIds) { RecipientId distributionRecipientId = SignalDatabase.distributionLists().getOrCreateByDistributionId(distributionId, manifest); Recipient distributionListRecipient = Recipient.resolved(distributionRecipientId); - insertSentStoryMessage(message, distributionListRecipient, textStoryBody, attachments, message.getTimestamp(), storyType, linkPreviews); + insertSentStoryMessage(message, distributionListRecipient, textStoryBody, attachments, message.getTimestamp(), storyType, linkPreviews, bodyRanges); } if (groupId.isPresent()) { Optional groupRecipient = SignalDatabase.recipients().getByGroupId(groupId.get()); if (groupRecipient.isPresent()) { - insertSentStoryMessage(message, Recipient.resolved(groupRecipient.get()), textStoryBody, attachments, message.getTimestamp(), storyType, linkPreviews); + insertSentStoryMessage(message, Recipient.resolved(groupRecipient.get()), textStoryBody, attachments, message.getTimestamp(), storyType, linkPreviews, bodyRanges); } } @@ -2235,7 +2238,8 @@ public class MessageContentProcessor { @NonNull List pendingAttachments, long sentAtTimestamp, @NonNull StoryType storyType, - @NonNull List linkPreviews) + @NonNull List linkPreviews, + @Nullable BodyRangeList bodyRanges) throws MmsException { if (SignalDatabase.messages().isOutgoingStoryAlreadyInDatabase(recipient.getId(), sentAtTimestamp)) { @@ -2262,7 +2266,7 @@ public class MessageContentProcessor { Collections.emptySet(), null, true, - null, + bodyRanges, -1); MessageTable messageTable = SignalDatabase.messages(); @@ -2335,6 +2339,7 @@ public class MessageContentProcessor { Optional> mentions = getMentions(message.getDataMessage().get().getMentions()); Optional giftBadge = getGiftBadge(message.getDataMessage().get().getGiftBadge()); boolean viewOnce = message.getDataMessage().get().isViewOnce(); + BodyRangeList bodyRanges = getBodyRangeList(message.getDataMessage().get().getBodyRanges()); List syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false)) : PointerAttachment.forPointers(message.getDataMessage().get().getAttachments()); @@ -2361,7 +2366,7 @@ public class MessageContentProcessor { Collections.emptySet(), giftBadge.orElse(null), true, - null, + bodyRanges, -1); if (recipients.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { @@ -3182,7 +3187,7 @@ public class MessageContentProcessor { } private @Nullable BodyRangeList getBodyRangeList(Optional> bodyRanges) { - if (!bodyRanges.isPresent()) { + if (bodyRanges.isEmpty()) { return null; }