From cc490f4b731eb6675e27439829dcfd8c1a68ce13 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 25 Jan 2023 10:31:36 -0500 Subject: [PATCH] Add text formatting send and receive support for conversations. --- .../securesms/components/ComposeText.java | 100 ++++++++++- .../securesms/components/InputPanel.java | 9 +- .../securesms/components/QuoteView.java | 8 +- .../conversation/ConversationDataSource.java | 8 +- .../conversation/ConversationItem.java | 5 +- .../conversation/ConversationMessage.java | 104 +++++------ .../ConversationParentFragment.java | 36 ++-- .../securesms/conversation/MessageStyler.kt | 126 ++++++++++++-- .../conversation/drafts/DraftRepository.kt | 17 +- .../conversation/drafts/DraftState.kt | 6 +- .../conversation/drafts/DraftViewModel.kt | 23 ++- .../forward/MultiselectForwardFragmentArgs.kt | 1 + .../quotes/MessageQuotesRepository.kt | 2 +- .../ConversationListItem.java | 62 ++++--- .../securesms/database/BodyAdjustment.kt | 6 + .../securesms/database/BodyRangeUtil.kt | 37 ++++ .../securesms/database/DraftTable.kt | 2 +- .../securesms/database/MentionUtil.java | 79 ++++----- .../securesms/database/MessageTable.java | 135 ++++++++++++--- .../securesms/database/ThreadBodyUtil.java | 69 ++++++-- .../securesms/database/ThreadTable.kt | 59 ++++--- .../database/model/BodyRangeListSerializer.kt | 14 ++ ...otoExtensions.kt => DatabaseProtosUtil.kt} | 29 ++++ .../database/model/MediaMmsMessageRecord.java | 6 +- .../database/model/MessageRecord.java | 4 +- .../securesms/database/model/Quote.java | 3 +- .../database/model/ThreadRecord.java | 9 + .../securesms/jobs/IndividualSendJob.java | 13 +- .../jobs/PushDistributionListSendJob.java | 14 +- .../securesms/jobs/PushGroupSendJob.java | 20 ++- .../securesms/jobs/PushSendJob.java | 53 +++++- .../mediasend/MediaSendActivityResult.kt | 21 +++ .../mediasend/v2/MediaSelectionRepository.kt | 13 +- .../mediasend/v2/MediaSelectionViewModel.kt | 22 +-- .../messages/MessageContentProcessor.java | 72 +++++--- .../securesms/mms/IncomingMediaMessage.kt | 7 +- .../securesms/mms/OutgoingMessage.kt | 29 +++- .../securesms/mms/QuoteModel.java | 10 +- .../notifications/RemoteReplyReceiver.java | 5 +- .../notifications/v2/NotificationItem.kt | 12 +- .../securesms/search/MessageResult.kt | 4 +- .../securesms/search/SearchRepository.java | 163 ++++++++++++------ .../securesms/sharing/MultiShareArgs.java | 37 +++- .../securesms/sharing/MultiShareSender.java | 14 +- .../securesms/stories/StoryTextPostModel.kt | 29 ++-- .../securesms/stories/StoryTextPostView.kt | 13 +- .../stories/viewer/AddToGroupStoryDelegate.kt | 1 + .../viewer/page/StoryViewerPageFragment.kt | 13 +- .../stories/viewer/post/StoryPostState.kt | 2 + .../stories/viewer/post/StoryPostViewModel.kt | 18 +- .../stories/viewer/post/StoryTextLoader.kt | 2 +- .../reply/composer/StoryReplyComposer.kt | 8 +- .../direct/StoryDirectReplyDialogFragment.kt | 3 +- .../direct/StoryDirectReplyRepository.kt | 30 ++-- .../reply/direct/StoryDirectReplyViewModel.kt | 19 +- .../reply/group/StoryGroupReplyFragment.kt | 14 +- .../reply/group/StoryGroupReplySender.kt | 31 +++- .../securesms/util/FeatureFlags.java | 14 +- .../securesms/util/SearchUtil.java | 16 +- app/src/main/proto/Database.proto | 7 +- app/src/main/res/values/ids.xml | 5 + app/src/main/res/values/strings.xml | 9 + .../securesms/database/BodyRangeUtilTest.kt | 118 +++++++++++++ .../securesms/database/MentionUtilTest.kt | 6 +- .../java/org/signal/core/util/StringUtil.java | 50 ++++++ .../core/util/StringUtilTest_endsWith.kt | 54 ++++++ .../core/util/StringUtilTest_replace.kt | 51 ++++++ .../core/util/StringUtilTest_startsWith.kt | 54 ++++++ .../api/SignalServiceMessageSender.java | 30 +++- .../api/messages/SignalServiceContent.java | 35 +++- .../api/messages/SignalServiceDataMessage.kt | 16 +- .../messages/SignalServiceStoryMessage.java | 35 ++-- .../src/main/proto/SignalService.proto | 29 +++- 73 files changed, 1664 insertions(+), 516 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/BodyAdjustment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/BodyRangeUtil.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/BodyRangeListSerializer.kt rename app/src/main/java/org/thoughtcrime/securesms/database/model/{DatabaseProtoExtensions.kt => DatabaseProtosUtil.kt} (54%) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/database/BodyRangeUtilTest.kt create mode 100644 core-util/src/test/java/org/signal/core/util/StringUtilTest_endsWith.kt create mode 100644 core-util/src/test/java/org/signal/core/util/StringUtilTest_replace.kt create mode 100644 core-util/src/test/java/org/signal/core/util/StringUtilTest_startsWith.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 704305fab..1f80b108f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components; import android.content.Context; import android.content.res.Configuration; import android.graphics.Canvas; +import android.graphics.Typeface; import android.os.Build; import android.os.Bundle; import android.text.Annotation; @@ -14,8 +15,15 @@ import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.text.TextUtils.TruncateAt; +import android.text.style.CharacterStyle; import android.text.style.RelativeSizeSpan; +import android.text.style.StrikethroughSpan; +import android.text.style.StyleSpan; +import android.text.style.TypefaceSpan; import android.util.AttributeSet; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; @@ -35,18 +43,19 @@ import org.thoughtcrime.securesms.components.mention.MentionDeleter; import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate; import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher; import org.thoughtcrime.securesms.conversation.MessageSendType; +import org.thoughtcrime.securesms.conversation.MessageStyler; import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery; import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener; import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryReplacement; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import java.util.List; import java.util.Objects; -import java.util.concurrent.TimeUnit; -import java.util.regex.Matcher; import java.util.regex.Pattern; import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER; @@ -276,6 +285,19 @@ public class ComposeText extends EmojiEditText { return MentionAnnotation.getMentionsFromAnnotations(getText()); } + public boolean hasStyling() { + CharSequence trimmed = getTextTrimmed(); + return FeatureFlags.textFormatting() && (trimmed instanceof Spanned) && MessageStyler.hasStyling((Spanned) trimmed); + } + + public @Nullable BodyRangeList getStyling() { + if (FeatureFlags.textFormatting()) { + return MessageStyler.getStyling(getTextTrimmed()); + } else { + return null; + } + } + private void initialize() { if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) { setImeOptions(getImeOptions() | 16777216); @@ -286,6 +308,80 @@ public class ComposeText extends EmojiEditText { addTextChangedListener(new MentionDeleter()); mentionValidatorWatcher = new MentionValidatorWatcher(); addTextChangedListener(mentionValidatorWatcher); + + if (FeatureFlags.textFormatting()) { + 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)); + + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode mode, MenuItem item) { + Editable text = getText(); + + if (text == null) { + return false; + } + + 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) { + return false; + } + + int start = getSelectionStart(); + int end = getSelectionEnd(); + + CharSequence charSequence = text.subSequence(start, end); + SpannableString replacement = new SpannableString(charSequence); + CharacterStyle 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(); + } + + if (style != null) { + replacement.setSpan(style, 0, charSequence.length(), Spannable.SPAN_EXCLUSIVE_INCLUSIVE); + } + + clearComposingText(); + + text.replace(start, end, replacement); + + 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/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java index 4d70b6a38..caf4aba1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -268,7 +268,14 @@ public class InputPanel extends LinearLayout public Optional getQuote() { if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) { - return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions(), quoteView.getQuoteType())); + return Optional.of(new QuoteModel(quoteView.getQuoteId(), + quoteView.getAuthor().getId(), + quoteView.getBody().toString(), + false, + quoteView.getAttachments(), + quoteView.getMentions(), + quoteView.getQuoteType(), + quoteView.getBodyRanges())); } else { return Optional.empty(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java index f02949711..6230a4d82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -29,7 +29,9 @@ import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.components.emoji.EmojiImageView; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme; +import org.thoughtcrime.securesms.conversation.MessageStyler; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.QuoteModel; @@ -437,7 +439,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { } try { - return StoryTextPostModel.parseFrom(body.toString(), id, author.getId()); + return StoryTextPostModel.parseFrom(body.toString(), id, author.getId(), MessageStyler.getStyling(body)); } catch (IOException ioException) { return null; } @@ -471,6 +473,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { return MentionAnnotation.getMentionsFromAnnotations(body); } + public @Nullable BodyRangeList getBodyRanges() { + return MessageStyler.getStyling(body); + } + private @NonNull ShapeAppearanceModel buildShapeAppearanceForLayoutDirection() { int fourDp = (int) DimensionUnit.DP.toPixels(4); if (getLayoutDirection() == LAYOUT_DIRECTION_LTR) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java index 340c23084..7096f2721 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -166,7 +166,7 @@ public class ConversationDataSource implements PagedDataSource messages = Stream.of(records) - .map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId()), quotedHelper.isQuoted(m.getId()))) + .map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, m.getDisplayBody(context), mentionHelper.getMentions(m.getId()), quotedHelper.isQuoted(m.getId()))) .toList(); stopwatch.split("conversion"); @@ -220,7 +220,11 @@ public class ConversationDataSource implements PagedDataSource new CharacterStyle[] { new BackgroundColorSpan(Color.YELLOW), new ForegroundColorSpan(Color.BLACK) }; + private ConversationMessage conversationMessage; private MessageRecord messageRecord; private Optional nextMessageRecord; @@ -988,8 +990,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (messageRequestAccepted) { linkifyMessageBody(styledText, batchSelected.isEmpty()); } - styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery, SearchUtil.STRICT); - styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery, SearchUtil.STRICT); + styledText = SearchUtil.getHighlightedSpan(locale, STYLE_FACTORY, styledText, searchQuery, SearchUtil.STRICT); if (hasExtraText(messageRecord)) { bodyText.setOverflowText(getLongMessageSpan(messageRecord)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index 573bb24e5..56572f86c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation; import android.content.Context; import android.text.SpannableString; -import androidx.annotation.AnyThread; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; @@ -12,6 +11,7 @@ import org.signal.core.util.Conversions; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection; +import org.thoughtcrime.securesms.database.BodyRangeUtil; import org.thoughtcrime.securesms.database.MentionUtil; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.Mention; @@ -35,22 +35,20 @@ public class ConversationMessage { @NonNull private final MessageStyler.Result styleResult; private final boolean hasBeenQuoted; - private ConversationMessage(@NonNull MessageRecord messageRecord, boolean hasBeenQuoted) { - this(messageRecord, null, null, hasBeenQuoted); - } - private ConversationMessage(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List mentions, - boolean hasBeenQuoted) + boolean hasBeenQuoted, + @Nullable MessageStyler.Result styleResult) { this.messageRecord = messageRecord; this.hasBeenQuoted = hasBeenQuoted; this.mentions = mentions != null ? mentions : Collections.emptyList(); + this.styleResult = styleResult != null ? styleResult : MessageStyler.Result.none(); if (body != null) { this.body = SpannableString.valueOf(body); - } else if (messageRecord.hasMessageRanges()) { + } else if (messageRecord.getMessageRanges() != null) { this.body = SpannableString.valueOf(messageRecord.getBody()); } else { this.body = null; @@ -60,12 +58,6 @@ public class ConversationMessage { MentionAnnotation.setMentionAnnotations(this.body, this.mentions); } - if (this.body != null && messageRecord.hasMessageRanges()) { - styleResult = MessageStyler.style(messageRecord.requireMessageRanges(), this.body); - } else { - styleResult = MessageStyler.Result.none(); - } - multiselectCollection = Multiselect.getParts(this); } @@ -128,32 +120,6 @@ public class ConversationMessage { */ public static class ConversationMessageFactory { - /** - * Creates a {@link ConversationMessage} wrapping the provided MessageRecord. No database or - * heavy work performed as the message is assumed to not have any mentions. - */ - @AnyThread - public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, boolean hasBeenQuoted) { - return new ConversationMessage(messageRecord, hasBeenQuoted); - } - - /** - * Creates a {@link ConversationMessage} wrapping the provided MessageRecord, potentially annotated body, and - * list of actual mentions. No database or heavy work performed as the body and mentions are assumed to be - * fully updated with display names. - * - * @param body Contains appropriate {@link MentionAnnotation}s and is updated with actual profile names. - * @param mentions List of actual mentions (i.e., not placeholder) matching annotation ranges in body. - * @param hasBeenQuoted Whether or not the message has been quoted by another message. - */ - @AnyThread - public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List mentions, boolean hasBeenQuoted) { - if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) { - return new ConversationMessage(messageRecord, body, mentions, hasBeenQuoted); - } - return new ConversationMessage(messageRecord, body, null, hasBeenQuoted); - } - /** * Creates a {@link ConversationMessage} wrapping the provided MessageRecord and will update and modify the provided * mentions from placeholder to actual. This method may perform database operations to resolve mentions to display names. @@ -161,12 +127,33 @@ public class ConversationMessage { * @param mentions List of placeholder mentions to be used to update the body in the provided MessageRecord. */ @WorkerThread - public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @Nullable List mentions, boolean hasBeenQuoted) { - if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) { - MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions); - return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), hasBeenQuoted); + public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, + @NonNull MessageRecord messageRecord, + @NonNull CharSequence body, + @Nullable List mentions, + boolean hasBeenQuoted) + { + SpannableString styledAndMentionBody = null; + MessageStyler.Result styleResult = MessageStyler.Result.none(); + + MentionUtil.UpdatedBodyAndMentions mentionsUpdate = null; + if (mentions != null && !mentions.isEmpty()) { + mentionsUpdate = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions); } - return createWithResolvedData(messageRecord, hasBeenQuoted); + + if (messageRecord.getMessageRanges() != null) { + BodyRangeList bodyRanges = mentionsUpdate == null ? messageRecord.getMessageRanges() + : BodyRangeUtil.adjustBodyRanges(messageRecord.getMessageRanges(), mentionsUpdate.getBodyAdjustments()); + + styledAndMentionBody = SpannableString.valueOf(mentionsUpdate != null ? mentionsUpdate.getBody() : body); + styleResult = MessageStyler.style(bodyRanges, styledAndMentionBody); + } + + return new ConversationMessage(messageRecord, + styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body, + mentionsUpdate != null ? mentionsUpdate.getMentions() : null, + hasBeenQuoted, + styleResult); } /** @@ -185,17 +172,10 @@ public class ConversationMessage { * database operations to query for mentions and then to resolve mentions to display names. */ @WorkerThread - public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) { - boolean hasBeenQuoted = SignalDatabase.messages().isQuoted(messageRecord); - - if (messageRecord.isMms()) { - List mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId()); - if (!mentions.isEmpty()) { - MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions); - return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), hasBeenQuoted); - } - } - return createWithResolvedData(messageRecord, body, null, hasBeenQuoted); + public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, boolean hasBeenQuoted) { + List mentions = messageRecord.isMms() ? SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId()) + : null; + return createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context), mentions, hasBeenQuoted); } /** @@ -204,15 +184,11 @@ public class ConversationMessage { * database operations to query for mentions and then to resolve mentions to display names. */ @WorkerThread - public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body, boolean hasBeenQuoted) { - if (messageRecord.isMms()) { - List mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId()); - if (!mentions.isEmpty()) { - MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions); - return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions(), hasBeenQuoted); - } - } - return createWithResolvedData(messageRecord, body, null, hasBeenQuoted); + public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) { + boolean hasBeenQuoted = SignalDatabase.messages().isQuoted(messageRecord); + List mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId()); + + return createWithUnresolvedData(context, messageRecord, body, mentions, hasBeenQuoted); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 779aa9eac..83d130b54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -186,6 +186,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.database.model.StoryType; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.GroupCallPeekEvent; import org.thoughtcrime.securesms.events.ReminderUpdateEvent; @@ -748,11 +749,12 @@ public class ConversationParentFragment extends Fragment return; } - long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds()); - boolean initiating = threadId == -1; - QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null); - SlideDeck slideDeck = new SlideDeck(); - List mentions = new ArrayList<>(result.getMentions()); + long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds()); + boolean initiating = threadId == -1; + QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orElse(null); + SlideDeck slideDeck = new SlideDeck(); + List mentions = new ArrayList<>(result.getMentions()); + BodyRangeList bodyRanges = result.getBodyRanges(); for (Media mediaItem : result.getNonUploadedMedia()) { if (MediaUtil.isVideoType(mediaItem.getMimeType())) { @@ -776,6 +778,7 @@ public class ConversationParentFragment extends Fragment Collections.emptyList(), Collections.emptyList(), mentions, + bodyRanges, expiresIn, result.isViewOnce(), initiating, @@ -1840,7 +1843,7 @@ public class ConversationParentFragment extends Fragment quoteResult.addListener(listener); break; case Draft.VOICE_NOTE: - case Draft.MENTION: + case Draft.BODY_RANGES: listener.onSuccess(true); break; } @@ -2704,7 +2707,7 @@ public class ConversationParentFragment extends Fragment long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds()); boolean initiating = threadId == -1; - sendMediaMessage(recipient.getId(), sendButton.getSelectedSendType(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), expiresIn, false, initiating, false, null); + sendMediaMessage(recipient.getId(), sendButton.getSelectedSendType(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), null, expiresIn, false, initiating, false, null); } private void selectContactInfo(ContactData contactData) { @@ -2943,6 +2946,7 @@ public class ConversationParentFragment extends Fragment recipient.getEmail().isPresent() || inputPanel.getQuote().isPresent() || composeText.hasMentions() || + composeText.hasStyling() || linkPreviewViewModel.hasLinkPreview() || needsSplit; @@ -2997,9 +3001,10 @@ public class ConversationParentFragment extends Fragment Collections.emptySet(), Collections.emptySet(), null, - true); + true, + result.getBodyRanges()); - final Context context = requireContext().getApplicationContext(); + final Context context = requireContext().getApplicationContext(); ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread); @@ -3032,6 +3037,7 @@ public class ConversationParentFragment extends Fragment Collections.emptyList(), linkPreviews, composeText.getMentions(), + composeText.getStyling(), expiresIn, viewOnce, initiating, @@ -3047,6 +3053,7 @@ public class ConversationParentFragment extends Fragment List contacts, List previews, List mentions, + @Nullable BodyRangeList styling, final long expiresIn, final boolean viewOnce, final boolean initiating, @@ -3093,7 +3100,8 @@ public class ConversationParentFragment extends Fragment Collections.emptySet(), Collections.emptySet(), null, - false); + false, + styling); final SettableFuture future = new SettableFuture<>(); final Context context = requireContext().getApplicationContext(); @@ -3154,7 +3162,7 @@ public class ConversationParentFragment extends Fragment OutgoingMessage message; if (sendPush) { - message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis()); + message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null); ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread); } else { message = OutgoingMessage.sms(recipient.get(), messageBody, sendType.getSimSubscriptionIdOr(-1)); @@ -3411,6 +3419,7 @@ public class ConversationParentFragment extends Fragment Collections.emptyList(), Collections.emptyList(), composeText.getMentions(), + composeText.getStyling(), expiresIn, false, initiating, @@ -3443,7 +3452,7 @@ public class ConversationParentFragment extends Fragment slideDeck.addSlide(stickerSlide); - sendMediaMessage(recipient.getId(), sendType, "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, initiating, clearCompose, null); + sendMediaMessage(recipient.getId(), sendType, "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), null, expiresIn, false, initiating, clearCompose, null); } private void silentlySetComposeText(String text) { @@ -3746,7 +3755,7 @@ public class ConversationParentFragment extends Fragment } private void handleSaveDraftOnTextChange(@NonNull CharSequence text) { - textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text))); + textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text))); } private void handleTypingIndicatorOnTextChange(@NonNull String text) { @@ -4185,6 +4194,7 @@ public class ConversationParentFragment extends Fragment Collections.emptyList(), Collections.emptyList(), composeText.getMentions(), + composeText.getStyling(), expiresIn, false, initiating, 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 ac06bd01d..9d0a58f55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MessageStyler.kt @@ -1,45 +1,133 @@ package org.thoughtcrime.securesms.conversation import android.graphics.Typeface -import android.text.SpannableString +import android.text.Spannable import android.text.Spanned import android.text.style.CharacterStyle +import android.text.style.StrikethroughSpan import android.text.style.StyleSpan import android.text.style.TypefaceSpan import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.PlaceholderURLSpan /** - * Helper for applying style-based [BodyRangeList.BodyRange]s to text. + * Helper for parsing and applying styles. Most notably with [BodyRangeList]. */ object MessageStyler { + const val MONOSPACE = "monospace" + @JvmStatic - fun style(messageRanges: BodyRangeList, span: SpannableString): Result { + fun boldStyle(): CharacterStyle { + return StyleSpan(Typeface.BOLD) + } + + @JvmStatic + fun italicStyle(): CharacterStyle { + return StyleSpan(Typeface.ITALIC) + } + + @JvmStatic + fun strikethroughStyle(): CharacterStyle { + return StrikethroughSpan() + } + + @JvmStatic + fun monoStyle(): CharacterStyle { + return TypefaceSpan(MONOSPACE) + } + + @JvmStatic + fun style(messageRanges: BodyRangeList?, span: Spannable): Result { + if (messageRanges == null) { + return Result.none() + } + + var appliedStyle = false var hasLinks = false var bottomButton: BodyRangeList.BodyRange.Button? = null - for (range in messageRanges.rangesList) { - if (range.hasStyle()) { + messageRanges + .rangesList + .filter { r -> r.start >= 0 && r.start < span.length && r.start + r.length >= 0 && r.start + r.length <= span.length } + .forEach { range -> + if (range.hasStyle()) { + val styleSpan: CharacterStyle? = when (range.style) { + BodyRangeList.BodyRange.Style.BOLD -> boldStyle() + BodyRangeList.BodyRange.Style.ITALIC -> italicStyle() + BodyRangeList.BodyRange.Style.STRIKETHROUGH -> strikethroughStyle() + BodyRangeList.BodyRange.Style.MONOSPACE -> monoStyle() + else -> null + } - val styleSpan: CharacterStyle? = when (range.style) { - BodyRangeList.BodyRange.Style.BOLD -> TypefaceSpan("sans-serif-medium") - BodyRangeList.BodyRange.Style.ITALIC -> StyleSpan(Typeface.ITALIC) - else -> null + if (styleSpan != null) { + span.setSpan(styleSpan, range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_INCLUSIVE) + appliedStyle = true + } + } else if (range.hasLink() && range.link != null) { + span.setSpan(PlaceholderURLSpan(range.link), range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + hasLinks = true + } else if (range.hasButton() && range.button != null) { + bottomButton = range.button } - - if (styleSpan != null) { - span.setSpan(styleSpan, range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - } else if (range.hasLink() && range.link != null) { - span.setSpan(PlaceholderURLSpan(range.link), range.start, range.start + range.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - hasLinks = true - } else if (range.hasButton() && range.button != null) { - bottomButton = range.button } + + return if (appliedStyle || hasLinks || bottomButton != null) { + Result(hasLinks, bottomButton) + } else { + Result.none() + } + } + + @JvmStatic + fun hasStyling(text: Spanned): Boolean { + return if (FeatureFlags.textFormatting()) { + text.getSpans(0, text.length, CharacterStyle::class.java) + .any { s -> isSupportedCharacterStyle(s) } + } else { + false + } + } + + @JvmStatic + fun getStyling(text: CharSequence?): BodyRangeList? { + val bodyRanges = if (text is Spanned && FeatureFlags.textFormatting()) { + text + .getSpans(0, text.length, CharacterStyle::class.java) + .filter { s -> isSupportedCharacterStyle(s) } + .map { span: CharacterStyle -> + val spanStart = text.getSpanStart(span) + val spanLength = text.getSpanEnd(span) - spanStart + + val style = when (span) { + is StyleSpan -> if (span.style == Typeface.BOLD) BodyRangeList.BodyRange.Style.BOLD else BodyRangeList.BodyRange.Style.ITALIC + is StrikethroughSpan -> BodyRangeList.BodyRange.Style.STRIKETHROUGH + is TypefaceSpan -> BodyRangeList.BodyRange.Style.MONOSPACE + else -> throw IllegalArgumentException("Provided text contains unsupported spans") + } + + BodyRangeList.BodyRange.newBuilder().setStart(spanStart).setLength(spanLength).setStyle(style).build() + } + .toList() + } else { + emptyList() } - return Result(hasLinks, bottomButton) + return if (bodyRanges.isNotEmpty()) { + BodyRangeList.newBuilder().addAllRanges(bodyRanges).build() + } else { + null + } + } + + private fun isSupportedCharacterStyle(style: CharacterStyle): Boolean { + return when (style) { + is StyleSpan -> style.style == Typeface.ITALIC || style.style == Typeface.BOLD + is StrikethroughSpan -> true + is TypefaceSpan -> style.family == MONOSPACE + else -> false + } } data class Result(val hasStyleLinks: Boolean = false, val bottomButton: BodyRangeList.BodyRange.Button? = null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt index 81a6bddcd..6ecbfeb98 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt @@ -8,12 +8,16 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.SignalExecutors import org.thoughtcrime.securesms.components.mention.MentionAnnotation +import org.thoughtcrime.securesms.conversation.MessageStyler import org.thoughtcrime.securesms.database.DraftTable import org.thoughtcrime.securesms.database.DraftTable.Drafts import org.thoughtcrime.securesms.database.MentionUtil import org.thoughtcrime.securesms.database.MessageTypes import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.ThreadTable +import org.thoughtcrime.securesms.database.adjustBodyRanges +import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient @@ -57,15 +61,18 @@ class DraftRepository( fun loadDrafts(threadId: Long): Single { return Single.fromCallable { val drafts: Drafts = draftTable.getDrafts(threadId) - val mentionsDraft = drafts.getDraftOfType(DraftTable.Draft.MENTION) + val bodyRangesDraft = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES) var updatedText: Spannable? = null - if (mentionsDraft != null) { - val text = drafts.getDraftOfType(DraftTable.Draft.TEXT)!!.value - val mentions = MentionUtil.bodyRangeListToMentions(context, Base64.decodeOrThrow(mentionsDraft.value)) - val updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, text, mentions) + if (bodyRangesDraft != null) { + val bodyRanges: BodyRangeList = BodyRangeList.parseFrom(Base64.decodeOrThrow(bodyRangesDraft.value)) + val mentions: List = MentionUtil.bodyRangeListToMentions(bodyRanges) + + val updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, drafts.getDraftOfType(DraftTable.Draft.TEXT)!!.value, mentions) + updatedText = SpannableString(updated.body) MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions) + MessageStyler.style(bodyRanges.adjustBodyRanges(updated.bodyAdjustments), updatedText) } DatabaseDraft(drafts, updatedText) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt index e5add72cf..abb0f9e90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt @@ -14,7 +14,7 @@ data class DraftState( val threadId: Long = -1, val distributionType: Int = 0, val textDraft: DraftTable.Draft? = null, - val mentionsDraft: DraftTable.Draft? = null, + val bodyRangesDraft: DraftTable.Draft? = null, val quoteDraft: DraftTable.Draft? = null, val locationDraft: DraftTable.Draft? = null, val voiceNoteDraft: DraftTable.Draft? = null, @@ -27,7 +27,7 @@ data class DraftState( fun toDrafts(): Drafts { return Drafts().apply { addIfNotNull(textDraft) - addIfNotNull(mentionsDraft) + addIfNotNull(bodyRangesDraft) addIfNotNull(quoteDraft) addIfNotNull(locationDraft) addIfNotNull(voiceNoteDraft) @@ -38,7 +38,7 @@ data class DraftState( return copy( threadId = threadId, textDraft = drafts.getDraftOfType(DraftTable.Draft.TEXT), - mentionsDraft = drafts.getDraftOfType(DraftTable.Draft.MENTION), + bodyRangesDraft = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES), quoteDraft = drafts.getDraftOfType(DraftTable.Draft.QUOTE), locationDraft = drafts.getDraftOfType(DraftTable.Draft.LOCATION), voiceNoteDraft = drafts.getDraftOfType(DraftTable.Draft.VOICE_NOTE), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt index 05f8539de..e8b91f9ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt @@ -64,9 +64,19 @@ class DraftViewModel @JvmOverloads constructor( store.update { it.copy(recipientId = recipient.id) } } - fun setTextDraft(text: String, mentions: List) { + fun setTextDraft(text: String, mentions: List, styleBodyRanges: BodyRangeList?) { store.update { - saveDrafts(it.copy(textDraft = text.toTextDraft(), mentionsDraft = mentions.toMentionsDraft())) + val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions) + + val bodyRanges: BodyRangeList? = if (styleBodyRanges == null) { + mentionRanges + } else if (mentionRanges == null) { + styleBodyRanges + } else { + styleBodyRanges.toBuilder().addAllRanges(mentionRanges.rangesList).build() + } + + saveDrafts(it.copy(textDraft = text.toTextDraft(), bodyRangesDraft = bodyRanges?.toDraft())) } } @@ -118,11 +128,6 @@ private fun String.toTextDraft(): Draft? { return if (isNotEmpty()) Draft(Draft.TEXT, this) else null } -private fun List.toMentionsDraft(): Draft? { - val mentions: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(this) - return if (mentions != null) { - Draft(Draft.MENTION, Base64.encodeBytes(mentions.toByteArray())) - } else { - null - } +private fun BodyRangeList.toDraft(): Draft { + return Draft(Draft.BODY_RANGES, Base64.encodeBytes(toByteArray())) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt index 538018565..043a69f6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragmentArgs.kt @@ -116,6 +116,7 @@ data class MultiselectForwardFragmentArgs @JvmOverloads constructor( .withMentions(conversationMessage.mentions) .withTimestamp(conversationMessage.messageRecord.timestamp) .withExpiration(conversationMessage.messageRecord.expireStarted + conversationMessage.messageRecord.expiresIn) + .withBodyRanges(conversationMessage.messageRecord.messageRanges) if (conversationMessage.multiselectCollection.isTextSelected(selectedParts)) { val mediaMessage: MmsMessageRecord? = conversationMessage.messageRecord as? MmsMessageRecord diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesRepository.kt index 73b34654e..5094a1012 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesRepository.kt @@ -99,7 +99,7 @@ class MessageQuotesRepository { .buildUpdatedModels(ApplicationDependencies.getApplication(), listOf(originalRecord)) .get(0) - val originalMessage: ConversationMessage = ConversationMessageFactory.createWithUnresolvedData(application, originalRecord, originalRecord.getDisplayBody(application), false) + val originalMessage: ConversationMessage = ConversationMessageFactory.createWithUnresolvedData(application, originalRecord, false) return replies + originalMessage } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java index e4b47a438..f8e5e243b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListItem.java @@ -22,6 +22,8 @@ import android.graphics.Typeface; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; +import android.text.style.CharacterStyle; +import android.text.style.ForegroundColorSpan; import android.text.style.StyleSpan; import android.util.AttributeSet; import android.view.TouchDelegate; @@ -58,6 +60,7 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView; import org.thoughtcrime.securesms.components.FromTextView; import org.thoughtcrime.securesms.components.TypingIndicatorView; import org.thoughtcrime.securesms.components.emoji.EmojiStrings; +import org.thoughtcrime.securesms.conversation.MessageStyler; import org.thoughtcrime.securesms.conversationlist.model.ConversationSet; import org.thoughtcrime.securesms.database.MessageTypes; import org.thoughtcrime.securesms.database.ThreadTable; @@ -121,8 +124,9 @@ public final class ConversationListItem extends ConstraintLayout implements Bind private int thumbSize; private GlideLiveDataTarget thumbTarget; - private int unreadCount; - private AvatarImageView contactPhotoImage; + private int unreadCount; + private AvatarImageView contactPhotoImage; + private SearchUtil.StyleFactory searchStyleFactory; private LiveData displayBody; @@ -153,6 +157,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind this.unreadMentions = findViewById(R.id.conversation_list_item_unread_mentions_indicator); this.thumbSize = (int) DimensionUnit.SP.toPixels(16f); this.thumbTarget = new GlideLiveDataTarget(thumbSize, thumbSize); + this.searchStyleFactory = () -> new CharacterStyle[] { new ForegroundColorSpan(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurface)), SpanUtil.getBoldSpan() }; getLayoutTransition().setDuration(150); } @@ -218,7 +223,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind if (highlightSubstring != null) { String name = recipient.get().isSelf() ? getContext().getString(R.string.note_to_self) : recipient.get().getDisplayName(getContext()); - this.fromView.setText(recipient.get(), SearchUtil.getHighlightedSpan(locale, SpanUtil::getMediumBoldSpan, name, highlightSubstring, SearchUtil.MATCH_ALL), true, null); + this.fromView.setText(recipient.get(), SearchUtil.getHighlightedSpan(locale, searchStyleFactory, name, highlightSubstring, SearchUtil.MATCH_ALL), true, null); } else { this.fromView.setText(recipient.get(), false); } @@ -274,8 +279,8 @@ public final class ConversationListItem extends ConstraintLayout implements Bind observeDisplayBody(null, null); setSubjectViewText(null); - fromView.setText(contact, SearchUtil.getHighlightedSpan(locale, SpanUtil::getMediumBoldSpan, new SpannableString(contact.getDisplayName(getContext())), highlightSubstring, SearchUtil.MATCH_ALL), true, null); - setSubjectViewText(SearchUtil.getHighlightedSpan(locale, SpanUtil::getBoldSpan, contact.getE164().orElse(""), highlightSubstring, SearchUtil.MATCH_ALL)); + fromView.setText(contact, SearchUtil.getHighlightedSpan(locale, searchStyleFactory, new SpannableString(contact.getDisplayName(getContext())), highlightSubstring, SearchUtil.MATCH_ALL), true, null); + setSubjectViewText(SearchUtil.getHighlightedSpan(locale, searchStyleFactory, contact.getE164().orElse(""), highlightSubstring, SearchUtil.MATCH_ALL)); dateView.setText(""); archivedView.setVisibility(GONE); unreadIndicator.setVisibility(GONE); @@ -303,7 +308,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind setSubjectViewText(null); fromView.setText(recipient.get(), false); - setSubjectViewText(SearchUtil.getHighlightedSpan(locale, SpanUtil::getBoldSpan, messageResult.getBodySnippet(), highlightSubstring, SearchUtil.MATCH_ALL)); + setSubjectViewText(SearchUtil.getHighlightedSpan(locale, searchStyleFactory, messageResult.getBodySnippet(), highlightSubstring, SearchUtil.MATCH_ALL)); dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.getReceivedTimestampMs())); archivedView.setVisibility(GONE); unreadIndicator.setVisibility(GONE); @@ -491,7 +496,7 @@ public final class ConversationListItem extends ConstraintLayout implements Bind if (highlightSubstring != null) { String name = recipient.isSelf() ? getContext().getString(R.string.note_to_self) : recipient.getDisplayName(getContext()); - fromView.setText(recipient, SearchUtil.getHighlightedSpan(locale, SpanUtil::getMediumBoldSpan, new SpannableString(name), highlightSubstring, SearchUtil.MATCH_ALL), true, null); + fromView.setText(recipient, SearchUtil.getHighlightedSpan(locale, searchStyleFactory, new SpannableString(name), highlightSubstring, SearchUtil.MATCH_ALL), true, null); } else { fromView.setText(recipient, false); } @@ -580,7 +585,10 @@ public final class ConversationListItem extends ConstraintLayout implements Bind } else if (extra != null && extra.isRemoteDelete()) { return emphasisAdded(context, context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted), defaultTint); } else { - String body = removeNewlines(thread.getBody()); + SpannableStringBuilder sourceBody = new SpannableStringBuilder(thread.getBody()); + MessageStyler.style(thread.getBodyRanges(), sourceBody); + + CharSequence body = StringUtil.replace(sourceBody, '\n', " "); LiveData finalBody = Transformations.map(createFinalBodyWithMediaIcon(context, body, thread, glideRequests, thumbSize, thumbTarget), updatedBody -> { if (thread.getRecipient().isGroup()) { RecipientId groupMessageSender = thread.getGroupMessageSender(); @@ -592,13 +600,13 @@ public final class ConversationListItem extends ConstraintLayout implements Bind return new SpannableString(updatedBody); }); - return whileLoadingShow(body, finalBody); + return whileLoadingShow(sourceBody, finalBody); } } } private static LiveData createFinalBodyWithMediaIcon(@NonNull Context context, - @NonNull String body, + @NonNull CharSequence body, @NonNull ThreadRecord thread, @NonNull GlideRequests glideRequests, @Px int thumbSize, @@ -608,16 +616,16 @@ public final class ConversationListItem extends ConstraintLayout implements Bind return LiveDataUtil.just(body); } - final String bodyWithoutMediaPrefix; + final SpannableStringBuilder bodyWithoutMediaPrefix = SpannableStringBuilder.valueOf(body); - if (body.startsWith(EmojiStrings.GIF)) { - bodyWithoutMediaPrefix = body.replaceFirst(EmojiStrings.GIF, ""); - } else if (body.startsWith(EmojiStrings.VIDEO)) { - bodyWithoutMediaPrefix = body.replaceFirst(EmojiStrings.VIDEO, ""); - } else if (body.startsWith(EmojiStrings.PHOTO)) { - bodyWithoutMediaPrefix = body.replaceFirst(EmojiStrings.PHOTO, ""); - } else if (thread.getExtra() != null && thread.getExtra().getStickerEmoji() != null && body.startsWith(thread.getExtra().getStickerEmoji())) { - bodyWithoutMediaPrefix = body.replaceFirst(thread.getExtra().getStickerEmoji(), ""); + if (StringUtil.startsWith(body, EmojiStrings.GIF)) { + bodyWithoutMediaPrefix.replace(0, EmojiStrings.GIF.length(), ""); + } else if (StringUtil.startsWith(body, EmojiStrings.VIDEO)) { + bodyWithoutMediaPrefix.replace(0, EmojiStrings.VIDEO.length(), ""); + } else if (StringUtil.startsWith(body, EmojiStrings.PHOTO)) { + bodyWithoutMediaPrefix.replace(0, EmojiStrings.PHOTO.length(), ""); + } else if (thread.getExtra() != null && thread.getExtra().getStickerEmoji() != null && StringUtil.startsWith(body, thread.getExtra().getStickerEmoji())) { + bodyWithoutMediaPrefix.replace(0, thread.getExtra().getStickerEmoji().length(), ""); } else { return LiveDataUtil.just(body); } @@ -668,20 +676,8 @@ public final class ConversationListItem extends ConstraintLayout implements Bind /** * After a short delay, if the main data hasn't shown yet, then a loading message is displayed. */ - private static @NonNull LiveData whileLoadingShow(@NonNull String loading, @NonNull LiveData string) { - return LiveDataUtil.until(string, LiveDataUtil.delay(250, new SpannableString(loading))); - } - - private static @NonNull String removeNewlines(@Nullable String text) { - if (text == null) { - return ""; - } - - if (text.indexOf('\n') >= 0) { - return text.replaceAll("\n", " "); - } else { - return text; - } + private static @NonNull LiveData whileLoadingShow(@NonNull CharSequence loading, @NonNull LiveData string) { + return LiveDataUtil.until(string, LiveDataUtil.delay(250, SpannableString.valueOf(loading))); } private static @NonNull LiveData emphasisAdded(@NonNull Context context, @NonNull String string, @ColorInt int defaultTint) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/BodyAdjustment.kt b/app/src/main/java/org/thoughtcrime/securesms/database/BodyAdjustment.kt new file mode 100644 index 000000000..91c5c57d9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/BodyAdjustment.kt @@ -0,0 +1,6 @@ +package org.thoughtcrime.securesms.database + +/** + * Store data about an operation that changes the contents of a body. + */ +data class BodyAdjustment(val startIndex: Int, val oldLength: Int, val newLength: Int) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/BodyRangeUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/BodyRangeUtil.kt new file mode 100644 index 000000000..30fb52667 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/BodyRangeUtil.kt @@ -0,0 +1,37 @@ +@file:JvmName("BodyRangeUtil") + +package org.thoughtcrime.securesms.database + +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList + +/** + * Given a list of body adjustment from removing mention names from a message and replacing + * them with a placeholder, we need to adjust the ranges within [BodyRangeList] to account + * for the now shorter text. + */ +fun BodyRangeList?.adjustBodyRanges(bodyAdjustments: List): BodyRangeList? { + if (this == null || bodyAdjustments.isEmpty()) { + return this + } + + val newBodyRanges = rangesList.toMutableList() + + for (adjustment in bodyAdjustments) { + val adjustmentLength = adjustment.oldLength - adjustment.newLength + + rangesList.forEachIndexed { listIndex, range -> + val needsRangeStartsAfterAdjustment = range.start > adjustment.startIndex + val needsRangeCoversAdjustment = range.start <= adjustment.startIndex && range.start + range.length >= adjustment.startIndex + adjustment.oldLength + + val newRange = newBodyRanges[listIndex] + val newStart: Int? = if (needsRangeStartsAfterAdjustment) newRange.start - adjustmentLength else null + val newLength: Int? = if (needsRangeCoversAdjustment) newRange.length - adjustmentLength else null + + if (newStart != null || newLength != null) { + newBodyRanges[listIndex] = newRange.toBuilder().setStart(newStart ?: newRange.start).setLength(newLength ?: newRange.length).build() + } + } + } + + return BodyRangeList.newBuilder().addAllRanges(newBodyRanges).build() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt index a5fd4d532..90c08e52d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt @@ -139,7 +139,7 @@ class DraftTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT const val AUDIO = "audio" const val LOCATION = "location" const val QUOTE = "quote" - const val MENTION = "mention" + const val BODY_RANGES = "mention" const val VOICE_NOTE = "voice_note" } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java index 0b20a9142..79e2d7894 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java @@ -10,10 +10,7 @@ import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; import com.annimon.stream.function.Function; -import com.google.protobuf.InvalidProtocolBufferException; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.RecipientTable.MentionSetting; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; @@ -35,25 +32,14 @@ public final class MentionUtil { private MentionUtil() { } @WorkerThread - public static @NonNull CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord) { - return updateBodyWithDisplayNames(context, messageRecord, messageRecord.getDisplayBody(context)); + public static @Nullable CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord) { + return updateBodyWithDisplayNames(context, messageRecord, messageRecord.getDisplayBody(context)).getBody(); } @WorkerThread - public static @NonNull CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) { - if (messageRecord.isMms()) { - List mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId()); - CharSequence updated = updateBodyAndMentionsWithDisplayNames(context, body, mentions).getBody(); - if (updated != null) { - return updated; - } - } - return body; - } - - @WorkerThread - public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull List mentions) { - return updateBodyAndMentionsWithDisplayNames(context, messageRecord.getDisplayBody(context), mentions); + public static @NonNull UpdatedBodyAndMentions updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) { + List mentions = SignalDatabase.mentions().getMentionsForMessage(messageRecord.getId()); + return updateBodyAndMentionsWithDisplayNames(context, body, mentions); } @WorkerThread @@ -68,12 +54,13 @@ public final class MentionUtil { @VisibleForTesting static @NonNull UpdatedBodyAndMentions update(@Nullable CharSequence body, @NonNull List mentions, @NonNull Function replacementTextGenerator) { if (body == null || mentions.isEmpty()) { - return new UpdatedBodyAndMentions(body, mentions); + return new UpdatedBodyAndMentions(body, mentions, Collections.emptyList()); } SortedSet sortedMentions = new TreeSet<>(mentions); SpannableStringBuilder updatedBody = new SpannableStringBuilder(); List updatedMentions = new ArrayList<>(); + List bodyAdjustments = new ArrayList<>(); int bodyIndex = 0; @@ -89,6 +76,8 @@ public final class MentionUtil { updatedBody.append(replaceWith); updatedMentions.add(updatedMention); + bodyAdjustments.add(new BodyAdjustment(mention.getStart(), mention.getLength(), updatedMention.getLength())); + bodyIndex = mention.getStart() + mention.getLength(); } @@ -96,7 +85,7 @@ public final class MentionUtil { updatedBody.append(body.subSequence(bodyIndex, body.length())); } - return new UpdatedBodyAndMentions(updatedBody.toString(), updatedMentions); + return new UpdatedBodyAndMentions(updatedBody, updatedMentions, bodyAdjustments); } public static @Nullable BodyRangeList mentionsToBodyRangeList(@Nullable List mentions) { @@ -117,34 +106,20 @@ public final class MentionUtil { return builder.build(); } - public static @NonNull List bodyRangeListToMentions(@NonNull Context context, @Nullable byte[] data) { - if (data != null) { - try { - return Stream.of(BodyRangeList.parseFrom(data).getRangesList()) - .filter(bodyRange -> bodyRange.getAssociatedValueCase() == BodyRangeList.BodyRange.AssociatedValueCase.MENTIONUUID) - .map(mention -> { - RecipientId id = Recipient.externalPush(ServiceId.parseOrThrow(mention.getMentionUuid())).getId(); - return new Mention(id, mention.getStart(), mention.getLength()); - }) - .toList(); - } catch (InvalidProtocolBufferException e) { - return Collections.emptyList(); - } + public static @NonNull List bodyRangeListToMentions(@Nullable BodyRangeList bodyRanges) { + if (bodyRanges != null) { + return Stream.of(bodyRanges.getRangesList()) + .filter(bodyRange -> bodyRange.getAssociatedValueCase() == BodyRangeList.BodyRange.AssociatedValueCase.MENTIONUUID) + .map(mention -> { + RecipientId id = Recipient.externalPush(ServiceId.parseOrThrow(mention.getMentionUuid())).getId(); + return new Mention(id, mention.getStart(), mention.getLength()); + }) + .toList(); } else { return Collections.emptyList(); } } - public static @NonNull String getMentionSettingDisplayValue(@NonNull Context context, @NonNull MentionSetting mentionSetting) { - switch (mentionSetting) { - case ALWAYS_NOTIFY: - return context.getString(R.string.GroupMentionSettingDialog_always_notify_me); - case DO_NOT_NOTIFY: - return context.getString(R.string.GroupMentionSettingDialog_dont_notify_me); - } - throw new IllegalArgumentException("Unknown mention setting: " + mentionSetting); - } - private static boolean invalidMention(@NonNull CharSequence body, @NonNull Mention mention) { int start = mention.getStart(); int length = mention.getLength(); @@ -153,12 +128,14 @@ public final class MentionUtil { } public static class UpdatedBodyAndMentions { - @Nullable private final CharSequence body; - @NonNull private final List mentions; + @Nullable private final CharSequence body; + @NonNull private final List mentions; + @NonNull private final List bodyAdjustments; - public UpdatedBodyAndMentions(@Nullable CharSequence body, @NonNull List mentions) { - this.body = body; - this.mentions = mentions; + private UpdatedBodyAndMentions(@Nullable CharSequence body, @NonNull List mentions, @NonNull List bodyAdjustments) { + this.body = body; + this.mentions = mentions; + this.bodyAdjustments = bodyAdjustments; } public @Nullable CharSequence getBody() { @@ -169,6 +146,10 @@ public final class MentionUtil { return mentions; } + public @NonNull List getBodyAdjustments() { + return bodyAdjustments; + } + @Nullable String getBodyAsString() { return body != null ? body.toString() : null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java index 4ff256c55..eb9f8427c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java @@ -19,6 +19,7 @@ package org.thoughtcrime.securesms.database; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; +import android.text.SpannableString; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -47,6 +48,7 @@ import org.thoughtcrime.securesms.attachments.AttachmentId; import org.thoughtcrime.securesms.attachments.DatabaseAttachment; import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment; import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.conversation.MessageStyler; import org.thoughtcrime.securesms.database.documents.Document; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet; @@ -169,7 +171,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie public static final String QUOTE_AUTHOR = "quote_author"; public static final String QUOTE_BODY = "quote_body"; public static final String QUOTE_MISSING = "quote_missing"; - public static final String QUOTE_MENTIONS = "quote_mentions"; + public static final String QUOTE_BODY_RANGES = "quote_mentions"; public static final String QUOTE_TYPE = "quote_type"; public static final String SHARED_CONTACTS = "shared_contacts"; public static final String LINK_PREVIEWS = "link_previews"; @@ -216,7 +218,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie QUOTE_AUTHOR + " INTEGER DEFAULT 0, " + QUOTE_BODY + " TEXT DEFAULT NULL, " + QUOTE_MISSING + " INTEGER DEFAULT 0, " + - QUOTE_MENTIONS + " BLOB DEFAULT NULL," + + QUOTE_BODY_RANGES + " BLOB DEFAULT NULL," + QUOTE_TYPE + " INTEGER DEFAULT 0," + SHARED_CONTACTS + " TEXT DEFAULT NULL, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " + @@ -281,7 +283,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie QUOTE_BODY, QUOTE_TYPE, QUOTE_MISSING, - QUOTE_MENTIONS, + QUOTE_BODY_RANGES, SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, @@ -2188,6 +2190,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.NETWORK_FAILURES)); StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID)); + byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES); long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)); long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)); @@ -2195,7 +2198,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie int quoteType = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_TYPE)); boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1; List quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList(); - List quoteMentions = parseQuoteMentions(context, cursor); + List quoteMentions = parseQuoteMentions(cursor); + BodyRangeList quoteBodyRanges = parseQuoteBodyRanges(cursor); List contacts = getSharedContacts(cursor, associatedAttachments); Set contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList()); List previews = getLinkPreviews(cursor, associatedAttachments); @@ -2212,7 +2216,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie QuoteModel quote = null; if (quoteId > 0 && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || !quoteAttachments.isEmpty())) { - quote = new QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteAttachments, quoteMentions, QuoteModel.Type.fromCode(quoteType)); + quote = new QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteAttachments, quoteMentions, QuoteModel.Type.fromCode(quoteType), quoteBodyRanges); } if (!TextUtils.isEmpty(mismatchDocument)) { @@ -2248,6 +2252,15 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie giftBadge = GiftBadge.parseFrom(Base64.decode(body)); } + BodyRangeList messageRanges = null; + if (messageRangesData != null) { + try { + messageRanges = BodyRangeList.parseFrom(messageRangesData); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Error parsing message ranges", e); + } + } + OutgoingMessage message = new OutgoingMessage(recipient, body, attachments, @@ -2266,7 +2279,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie networkFailures, mismatches, giftBadge, - MessageTypes.isSecureType(outboxType)); + MessageTypes.isSecureType(outboxType), + messageRanges); return message; } @@ -2398,14 +2412,21 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie if (retrieved.getQuote() != null) { contentValues.put(QUOTE_ID, retrieved.getQuote().getId()); - contentValues.put(QUOTE_BODY, retrieved.getQuote().getText().toString()); + contentValues.put(QUOTE_BODY, retrieved.getQuote().getText()); contentValues.put(QUOTE_AUTHOR, retrieved.getQuote().getAuthor().serialize()); contentValues.put(QUOTE_TYPE, retrieved.getQuote().getType().getCode()); contentValues.put(QUOTE_MISSING, retrieved.getQuote().isOriginalMissing() ? 1 : 0); + BodyRangeList.Builder quoteBodyRanges = retrieved.getQuote().getBodyRanges() != null ? retrieved.getQuote().getBodyRanges().toBuilder() + : BodyRangeList.newBuilder(); + BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(retrieved.getQuote().getMentions()); if (mentionsList != null) { - contentValues.put(QUOTE_MENTIONS, mentionsList.toByteArray()); + quoteBodyRanges.addAllRanges(mentionsList.getRangesList()); + } + + if (quoteBodyRanges.getRangesCount() > 0) { + contentValues.put(QUOTE_BODY_RANGES, quoteBodyRanges.build().toByteArray()); } quoteAttachments = retrieved.getQuote().getAttachments(); @@ -2789,17 +2810,30 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie contentValues.put(QUOTE_TYPE, message.getOutgoingQuote().getType().getCode()); contentValues.put(QUOTE_MISSING, message.getOutgoingQuote().isOriginalMissing() ? 1 : 0); + BodyRangeList adjustedQuoteBodyRanges = BodyRangeUtil.adjustBodyRanges(message.getOutgoingQuote().getBodyRanges(), updated.getBodyAdjustments()); + BodyRangeList.Builder quoteBodyRanges; + if (adjustedQuoteBodyRanges != null) { + quoteBodyRanges = adjustedQuoteBodyRanges.toBuilder(); + } else { + quoteBodyRanges = BodyRangeList.newBuilder(); + } + BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(updated.getMentions()); if (mentionsList != null) { - contentValues.put(QUOTE_MENTIONS, mentionsList.toByteArray()); + quoteBodyRanges.addAllRanges(mentionsList.getRangesList()); + } + + if (quoteBodyRanges.getRangesCount() > 0) { + contentValues.put(QUOTE_BODY_RANGES, quoteBodyRanges.build().toByteArray()); } quoteAttachments.addAll(message.getOutgoingQuote().getAttachments()); } MentionUtil.UpdatedBodyAndMentions updatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getBody(), message.getMentions()); + BodyRangeList bodyRanges = BodyRangeUtil.adjustBodyRanges(message.getBodyRanges(), updatedBodyAndMentions.getBodyAdjustments()); - long messageId = insertMediaMessage(threadId, updatedBodyAndMentions.getBodyAsString(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), updatedBodyAndMentions.getMentions(), null, contentValues, insertListener, false, false); + long messageId = insertMediaMessage(threadId, updatedBodyAndMentions.getBodyAsString(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), updatedBodyAndMentions.getMentions(), bodyRanges, contentValues, insertListener, false, false); if (message.getRecipient().isGroup()) { GroupReceiptTable receiptDatabase = SignalDatabase.groupReceipts(); @@ -3281,10 +3315,37 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie } } - private static @NonNull List parseQuoteMentions(@NonNull Context context, Cursor cursor) { - byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_MENTIONS)); + private static @NonNull List parseQuoteMentions(@NonNull Cursor cursor) { + byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_BODY_RANGES)); + BodyRangeList bodyRanges = null; - return MentionUtil.bodyRangeListToMentions(context, raw); + if (raw != null) { + try { + bodyRanges = BodyRangeList.parseFrom(raw); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Unable to parse quote body ranges", e); + } + } + + return MentionUtil.bodyRangeListToMentions(bodyRanges); + } + + private static @Nullable BodyRangeList parseQuoteBodyRanges(@NonNull Cursor cursor) { + byte[] data = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_BODY_RANGES)); + + if (data != null) { + try { + final List bodyRanges = Stream.of(BodyRangeList.parseFrom(data).getRangesList()) + .filter(bodyRange -> bodyRange.getAssociatedValueCase() != BodyRangeList.BodyRange.AssociatedValueCase.MENTIONUUID) + .toList(); + + return BodyRangeList.newBuilder().addAllRanges(bodyRanges).build(); + } catch (InvalidProtocolBufferException e) { + // Intentionally left blank + } + } + + return null; } public SQLiteDatabase beginTransaction() { @@ -4574,6 +4635,32 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie } } + public @NonNull Map getBodyRangesForMessages(@NonNull List messageIds) { + List queries = SqlUtil.buildCollectionQuery(ID, messageIds); + Map bodyRanges = new HashMap<>(); + + for (SqlUtil.Query query : queries) { + try (Cursor cursor = SQLiteDatabaseExtensionsKt.select(getReadableDatabase(), ID, MESSAGE_RANGES) + .from(TABLE_NAME) + .where(query.getWhere(), query.getWhereArgs()) + .run()) + { + while (cursor.moveToNext()) { + byte[] data = CursorUtil.requireBlob(cursor, MESSAGE_RANGES); + if (data != null) { + try { + bodyRanges.put(CursorUtil.requireLong(cursor, ID), BodyRangeList.parseFrom(data)); + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Unable to parse body ranges for search", e); + } + } + } + } + } + + return bodyRanges; + } + protected enum ReceiptType { READ(READ_RECEIPT_COUNT, GroupReceiptTable.STATUS_READ), DELIVERY(DELIVERY_RECEIPT_COUNT, GroupReceiptTable.STATUS_DELIVERED), @@ -4922,13 +5009,17 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie public MessageRecord getCurrent() { SlideDeck slideDeck = new SlideDeck(context, message.getAttachments()); - CharSequence quoteText = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getText() : null; - List quoteMentions = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getMentions() : Collections.emptyList(); + CharSequence quoteText = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getText() : null; + List quoteMentions = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getMentions() : Collections.emptyList(); + BodyRangeList quoteBodyRanges = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getBodyRanges() : null; - if (quoteText != null && !quoteMentions.isEmpty()) { + if (quoteText != null && (Util.hasItems(quoteMentions) || quoteBodyRanges != null)) { MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions); - quoteText = updated.getBody(); + SpannableString styledText = new SpannableString(updated.getBody()); + MessageStyler.style(BodyRangeUtil.adjustBodyRanges(quoteBodyRanges, updated.getBodyAdjustments()), styledText); + + quoteText = styledText; quoteMentions = updated.getMentions(); } @@ -5202,16 +5293,20 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie CharSequence quoteText = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.QUOTE_BODY)); int quoteType = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.QUOTE_TYPE)); boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.QUOTE_MISSING)) == 1; - List quoteMentions = parseQuoteMentions(context, cursor); + List quoteMentions = parseQuoteMentions(cursor); + BodyRangeList bodyRanges = parseQuoteBodyRanges(cursor); List attachments = SignalDatabase.attachments().getAttachments(cursor); List quoteAttachments = Stream.of(attachments).filter(Attachment::isQuote).toList(); SlideDeck quoteDeck = new SlideDeck(context, quoteAttachments); if (quoteId > 0 && quoteAuthor > 0) { - if (quoteText != null && !quoteMentions.isEmpty()) { + if (quoteText != null && (Util.hasItems(quoteMentions) || bodyRanges != null)) { MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions); - quoteText = updated.getBody(); + SpannableString styledText = new SpannableString(updated.getBody()); + MessageStyler.style(BodyRangeUtil.adjustBodyRanges(bodyRanges, updated.getBodyAdjustments()), styledText); + + quoteText = styledText; quoteMentions = updated.getMentions(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java index ffb37da42..2df9d0654 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java @@ -19,6 +19,8 @@ import org.thoughtcrime.securesms.mms.StickerSlide; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.Util; +import java.util.Collections; +import java.util.List; import java.util.Objects; public final class ThreadBodyUtil { @@ -28,19 +30,19 @@ public final class ThreadBodyUtil { private ThreadBodyUtil() { } - public static @NonNull String getFormattedBodyFor(@NonNull Context context, @NonNull MessageRecord record) { + public static @NonNull ThreadBody getFormattedBodyFor(@NonNull Context context, @NonNull MessageRecord record) { if (record.isMms()) { return getFormattedBodyForMms(context, (MmsMessageRecord) record); } - return record.getBody(); + return new ThreadBody(record.getBody()); } - private static @NonNull String getFormattedBodyForMms(@NonNull Context context, @NonNull MmsMessageRecord record) { + private static @NonNull ThreadBody getFormattedBodyForMms(@NonNull Context context, @NonNull MmsMessageRecord record) { if (record.getSharedContacts().size() > 0) { Contact contact = record.getSharedContacts().get(0); - return ContactUtil.getStringSummary(context, contact).toString(); + return new ThreadBody(ContactUtil.getStringSummary(context, contact).toString()); } else if (record.getSlideDeck().getDocumentSlide() != null) { return format(context, record, EmojiStrings.FILE, R.string.ThreadRecord_file); } else if (record.getSlideDeck().getAudioSlide() != null) { @@ -49,17 +51,17 @@ public final class ThreadBodyUtil { String emoji = getStickerEmoji(record); return format(context, record, emoji, R.string.ThreadRecord_sticker); } else if (MessageRecordUtil.hasGiftBadge(record)) { - return String.format("%s %s", EmojiStrings.GIFT, getGiftSummary(context, record)); + return format(EmojiStrings.GIFT, getGiftSummary(context, record)); } else if (MessageRecordUtil.isStoryReaction(record)) { - return getStoryReactionSummary(context, record); + return new ThreadBody(getStoryReactionSummary(context, record)); } else if (record.isPaymentNotification()) { - return String.format("%s %s", EmojiStrings.CARD, context.getString(R.string.ThreadRecord_payment)); + return format(EmojiStrings.CARD, context.getString(R.string.ThreadRecord_payment)); } else if (record.isPaymentsRequestToActivate()) { - return String.format("%s %s", EmojiStrings.CARD, getPaymentActivationRequestSummary(context, record)); + return format(EmojiStrings.CARD, getPaymentActivationRequestSummary(context, record)); } else if (record.isPaymentsActivated()) { - return String.format("%s %s", EmojiStrings.CARD, getPaymentActivatedSummary(context, record)); + return format(EmojiStrings.CARD, getPaymentActivatedSummary(context, record)); } else if (record.isCallLog() && !record.isGroupCall()) { - return getCallLogSummary(context, record); + return new ThreadBody(getCallLogSummary(context, record)); } boolean hasImage = false; @@ -79,7 +81,7 @@ public final class ThreadBodyUtil { } else if (hasImage) { return format(context, record, EmojiStrings.PHOTO, R.string.ThreadRecord_photo); } else if (TextUtils.isEmpty(record.getBody())) { - return context.getString(R.string.ThreadRecord_media_message); + return new ThreadBody(context.getString(R.string.ThreadRecord_media_message)); } else { return getBody(context, record); } @@ -146,16 +148,23 @@ public final class ThreadBodyUtil { } } - private static @NonNull String format(@NonNull Context context, @NonNull MessageRecord record, @NonNull String emoji, @StringRes int defaultStringRes) { - return String.format("%s %s", emoji, getBodyOrDefault(context, record, defaultStringRes)); + private static @NonNull ThreadBody format(@NonNull Context context, @NonNull MessageRecord record, @NonNull String emoji, @StringRes int defaultStringRes) { + CharSequence body = getBodyOrDefault(context, record, defaultStringRes).getBody(); + return format(emoji, body); } - private static @NonNull String getBodyOrDefault(@NonNull Context context, @NonNull MessageRecord record, @StringRes int defaultStringRes) { - return TextUtils.isEmpty(record.getBody()) ? context.getString(defaultStringRes) : getBody(context, record); + private static @NonNull ThreadBody format(@NonNull CharSequence prefix, @NonNull CharSequence body) { + return new ThreadBody(String.format("%s %s", prefix, body), prefix.length() + 1); } - private static @NonNull String getBody(@NonNull Context context, @NonNull MessageRecord record) { - return MentionUtil.updateBodyWithDisplayNames(context, record, record.getBody()).toString(); + private static @NonNull ThreadBody getBodyOrDefault(@NonNull Context context, @NonNull MessageRecord record, @StringRes int defaultStringRes) { + return TextUtils.isEmpty(record.getBody()) ? new ThreadBody(context.getString(defaultStringRes)) : getBody(context, record); + } + + private static @NonNull ThreadBody getBody(@NonNull Context context, @NonNull MessageRecord record) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyWithDisplayNames(context, record, record.getBody()); + //noinspection ConstantConditions + return new ThreadBody(updated.getBody(), updated.getBodyAdjustments()); } private static @NonNull String getStickerEmoji(@NonNull MessageRecord record) { @@ -164,4 +173,30 @@ public final class ThreadBodyUtil { return Util.isEmpty(slide.getEmoji()) ? EmojiStrings.STICKER : slide.getEmoji(); } + + public static class ThreadBody { + private final CharSequence body; + private final List bodyAdjustments; + + public ThreadBody(@NonNull CharSequence body) { + this(body, 0); + } + + public ThreadBody(@NonNull CharSequence body, int startOffset) { + this(body, startOffset == 0 ? Collections.emptyList() : Collections.singletonList(new BodyAdjustment(0, 0, startOffset))); + } + + public ThreadBody(@NonNull CharSequence body, @NonNull List bodyAdjustments) { + this.body = body; + this.bodyAdjustments = bodyAdjustments; + } + + public @NonNull CharSequence getBody() { + return body; + } + + public @NonNull List getBodyAdjustments() { + return bodyAdjustments; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index a5983f114..e2be4ca08 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -36,10 +36,13 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mentions import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messageLog import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients +import org.thoughtcrime.securesms.database.ThreadBodyUtil.ThreadBody import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.database.model.serialize import org.thoughtcrime.securesms.groups.BadGroupIdException import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -1376,13 +1379,15 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } } + val threadBody: ThreadBody = ThreadBodyUtil.getFormattedBodyFor(context, record) + updateThread( threadId = threadId, meaningfulMessages = meaningfulMessages, - body = ThreadBodyUtil.getFormattedBodyFor(context, record), + body = threadBody.body.toString(), attachment = getAttachmentUriFor(record), contentType = getContentTypeFor(record), - extra = getExtrasFor(record), + extra = getExtrasFor(record, threadBody), date = record.timestamp, status = record.deliveryStatus, deliveryReceiptCount = record.deliveryReceiptCount, @@ -1534,7 +1539,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa return null } - private fun getExtrasFor(record: MessageRecord): Extra? { + private fun getExtrasFor(record: MessageRecord, body: ThreadBody): Extra? { val threadRecipient = if (record.isOutgoing) record.recipient else getRecipientForThreadId(record.threadId) val messageRequestAccepted = RecipientUtil.isMessageRequestAccepted(record.threadId, threadRecipient) val individualRecipientId = record.individualRecipient.id @@ -1567,7 +1572,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } } - return if (record.isRemoteDelete) { + val extras: Extra? = if (record.isRemoteDelete) { Extra.forRemoteDelete(individualRecipientId) } else if (record.isViewOnce) { Extra.forViewOnce(individualRecipientId) @@ -1581,6 +1586,13 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } else { null } + + return if (record.messageRanges != null) { + val bodyRanges = record.requireMessageRanges().adjustBodyRanges(body.bodyAdjustments)!! + extras?.copy(bodyRanges = bodyRanges.serialize()) ?: Extra.forBodyRanges(bodyRanges, individualRecipientId) + } else { + extras + } } private fun createQuery(where: String, limit: Long): String { @@ -1754,15 +1766,16 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } data class Extra( - @field:JsonProperty @param:JsonProperty("isRevealable") val isViewOnce: Boolean, - @field:JsonProperty @param:JsonProperty("isSticker") val isSticker: Boolean, - @field:JsonProperty @param:JsonProperty("stickerEmoji") val stickerEmoji: String?, - @field:JsonProperty @param:JsonProperty("isAlbum") val isAlbum: Boolean, - @field:JsonProperty @param:JsonProperty("isRemoteDelete") val isRemoteDelete: Boolean, - @field:JsonProperty @param:JsonProperty("isMessageRequestAccepted") val isMessageRequestAccepted: Boolean, - @field:JsonProperty @param:JsonProperty("isGv2Invite") val isGv2Invite: Boolean, - @field:JsonProperty @param:JsonProperty("groupAddedBy") val groupAddedBy: String?, - @field:JsonProperty @param:JsonProperty("individualRecipientId") private val individualRecipientId: String + @field:JsonProperty @param:JsonProperty("isRevealable") val isViewOnce: Boolean = false, + @field:JsonProperty @param:JsonProperty("isSticker") val isSticker: Boolean = false, + @field:JsonProperty @param:JsonProperty("stickerEmoji") val stickerEmoji: String? = null, + @field:JsonProperty @param:JsonProperty("isAlbum") val isAlbum: Boolean = false, + @field:JsonProperty @param:JsonProperty("isRemoteDelete") val isRemoteDelete: Boolean = false, + @field:JsonProperty @param:JsonProperty("isMessageRequestAccepted") val isMessageRequestAccepted: Boolean = true, + @field:JsonProperty @param:JsonProperty("isGv2Invite") val isGv2Invite: Boolean = false, + @field:JsonProperty @param:JsonProperty("groupAddedBy") val groupAddedBy: String? = null, + @field:JsonProperty @param:JsonProperty("individualRecipientId") private val individualRecipientId: String, + @field:JsonProperty @param:JsonProperty("bodyRanges") val bodyRanges: String? = null ) { fun getIndividualRecipientId(): String { @@ -1771,35 +1784,39 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa companion object { fun forViewOnce(individualRecipient: RecipientId): Extra { - return Extra(isViewOnce = true, isSticker = false, stickerEmoji = null, isAlbum = false, isRemoteDelete = false, isMessageRequestAccepted = true, isGv2Invite = false, groupAddedBy = null, individualRecipientId = individualRecipient.serialize()) + return Extra(isViewOnce = true, individualRecipientId = individualRecipient.serialize()) } fun forSticker(emoji: String?, individualRecipient: RecipientId): Extra { - return Extra(isViewOnce = false, isSticker = true, stickerEmoji = emoji, isAlbum = false, isRemoteDelete = false, isMessageRequestAccepted = true, isGv2Invite = false, groupAddedBy = null, individualRecipientId = individualRecipient.serialize()) + return Extra(isSticker = true, stickerEmoji = emoji, individualRecipientId = individualRecipient.serialize()) } fun forAlbum(individualRecipient: RecipientId): Extra { - return Extra(isViewOnce = false, isSticker = false, stickerEmoji = null, isAlbum = true, isRemoteDelete = false, isMessageRequestAccepted = true, isGv2Invite = false, groupAddedBy = null, individualRecipientId = individualRecipient.serialize()) + return Extra(isAlbum = true, individualRecipientId = individualRecipient.serialize()) } fun forRemoteDelete(individualRecipient: RecipientId): Extra { - return Extra(isViewOnce = false, isSticker = false, stickerEmoji = null, isAlbum = false, isRemoteDelete = true, isMessageRequestAccepted = true, isGv2Invite = false, groupAddedBy = null, individualRecipientId = individualRecipient.serialize()) + return Extra(isRemoteDelete = true, individualRecipientId = individualRecipient.serialize()) } fun forMessageRequest(individualRecipient: RecipientId): Extra { - return Extra(isViewOnce = false, isSticker = false, stickerEmoji = null, isAlbum = false, isRemoteDelete = false, isMessageRequestAccepted = false, isGv2Invite = false, groupAddedBy = null, individualRecipientId = individualRecipient.serialize()) + return Extra(isMessageRequestAccepted = false, individualRecipientId = individualRecipient.serialize()) } fun forGroupMessageRequest(recipientId: RecipientId, individualRecipient: RecipientId): Extra { - return Extra(isViewOnce = false, isSticker = false, stickerEmoji = null, isAlbum = false, isRemoteDelete = false, isMessageRequestAccepted = false, isGv2Invite = false, groupAddedBy = recipientId.serialize(), individualRecipientId = individualRecipient.serialize()) + return Extra(isMessageRequestAccepted = false, groupAddedBy = recipientId.serialize(), individualRecipientId = individualRecipient.serialize()) } fun forGroupV2invite(recipientId: RecipientId, individualRecipient: RecipientId): Extra { - return Extra(isViewOnce = false, isSticker = false, stickerEmoji = null, isAlbum = false, isRemoteDelete = false, isMessageRequestAccepted = false, isGv2Invite = true, groupAddedBy = recipientId.serialize(), individualRecipientId = individualRecipient.serialize()) + return Extra(isGv2Invite = true, groupAddedBy = recipientId.serialize(), individualRecipientId = individualRecipient.serialize()) } fun forDefault(individualRecipient: RecipientId): Extra { - return Extra(isViewOnce = false, isSticker = false, stickerEmoji = null, isAlbum = false, isRemoteDelete = false, isMessageRequestAccepted = true, isGv2Invite = false, groupAddedBy = null, individualRecipientId = individualRecipient.serialize()) + return Extra(individualRecipientId = individualRecipient.serialize()) + } + + fun forBodyRanges(bodyRanges: BodyRangeList, individualRecipient: RecipientId): Extra { + return Extra(individualRecipientId = individualRecipient.serialize(), bodyRanges = bodyRanges.serialize()) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/BodyRangeListSerializer.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/BodyRangeListSerializer.kt new file mode 100644 index 000000000..e90a385d6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/BodyRangeListSerializer.kt @@ -0,0 +1,14 @@ +package org.thoughtcrime.securesms.database.model + +import org.signal.core.util.StringSerializer +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.util.Base64 + +object BodyRangeListSerializer : StringSerializer { + override fun serialize(data: BodyRangeList): String = Base64.encodeBytes(data.toByteArray()) + override fun deserialize(data: String): BodyRangeList = BodyRangeList.parseFrom(Base64.decode(data)) +} + +fun BodyRangeList.serialize(): String { + return BodyRangeListSerializer.serialize(this) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtoExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtosUtil.kt similarity index 54% rename from app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtoExtensions.kt rename to app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtosUtil.kt index c34df7783..87bc6a27a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtoExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/DatabaseProtosUtil.kt @@ -1,7 +1,11 @@ +@file:JvmName("DatabaseProtosUtil") + package org.thoughtcrime.securesms.database.model import com.google.protobuf.ByteString import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.util.FeatureFlags +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.BodyRange /** * Collection of extensions to make working with database protos cleaner. @@ -43,3 +47,28 @@ fun BodyRangeList.Builder.addButton(label: String, action: String, start: Int, l return this } + +fun List?.toBodyRangeList(): BodyRangeList? { + if (this == null || !FeatureFlags.textFormatting()) { + return null + } + + val builder = BodyRangeList.newBuilder() + + for (bodyRange in this) { + var style: BodyRangeList.BodyRange.Style? = null + when (bodyRange.style) { + BodyRange.Style.BOLD -> style = BodyRangeList.BodyRange.Style.BOLD + BodyRange.Style.ITALIC -> style = BodyRangeList.BodyRange.Style.ITALIC + BodyRange.Style.SPOILER -> Unit + BodyRange.Style.STRIKETHROUGH -> style = BodyRangeList.BodyRange.Style.STRIKETHROUGH + BodyRange.Style.MONOSPACE -> style = BodyRangeList.BodyRange.Style.MONOSPACE + else -> Unit + } + if (style != null) { + builder.addStyle(style, bodyRange.start, bodyRange.length) + } + } + + return builder.build() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index c6cb63f23..755802323 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -179,15 +179,11 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { return super.getUpdateDisplayBody(context, recipientClickHandler); } + @Override public @Nullable BodyRangeList getMessageRanges() { return messageRanges; } - @Override - public boolean hasMessageRanges() { - return messageRanges != null; - } - @Override public @NonNull BodyRangeList requireMessageRanges() { return Objects.requireNonNull(messageRanges); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index d40ef30df..bafaf9855 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -703,8 +703,8 @@ public abstract class MessageRecord extends DisplayRecord { return isJumboji; } - public boolean hasMessageRanges() { - return false; + public @Nullable BodyRangeList getMessageRanges() { + return null; } public @NonNull BodyRangeList requireMessageRanges() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java index de84d5ee8..036fed906 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/Quote.java @@ -38,7 +38,7 @@ public class Quote { this.mentions = mentions; this.quoteType = quoteType; - SpannableString spannable = new SpannableString(Util.emptyIfNull(text)); + SpannableString spannable = SpannableString.valueOf(Util.emptyIfNull(text)); MentionAnnotation.setMentionAnnotations(spannable, mentions); this.text = spannable; @@ -48,7 +48,6 @@ public class Quote { return new Quote(id, author, text, missing, updatedAttachment, mentions, quoteType); } - public long getId() { return id; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java index 9e8229db7..e58c52582 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/ThreadRecord.java @@ -25,6 +25,7 @@ import androidx.annotation.Nullable; import org.thoughtcrime.securesms.database.MessageTypes; import org.thoughtcrime.securesms.database.ThreadTable; import org.thoughtcrime.securesms.database.ThreadTable.Extra; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.util.Preconditions; @@ -100,6 +101,14 @@ public final class ThreadRecord { return extra; } + public @Nullable BodyRangeList getBodyRanges() { + if (extra != null && extra.getBodyRanges() != null) { + return BodyRangeListSerializer.INSTANCE.deserialize(extra.getBodyRanges()); + } else { + return null; + } + } + public @Nullable String getContentType() { return contentType; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java index cc52c7611..74b7c8b70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java @@ -49,7 +49,8 @@ import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredExcepti import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException; import org.whispersystems.signalservice.api.util.UuidUtil; -import org.whispersystems.signalservice.internal.push.SignalServiceProtos; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.BodyRange; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage; import java.io.FileNotFoundException; import java.io.IOException; @@ -231,6 +232,7 @@ public class IndividualSendJob extends PushSendJob { List previews = getPreviewsFor(message); SignalServiceDataMessage.GiftBadge giftBadge = getGiftBadgeFor(message); SignalServiceDataMessage.Payment payment = getPayment(message); + List bodyRanges = getBodyRanges(message); SignalServiceDataMessage.Builder mediaMessageBuilder = SignalServiceDataMessage.newBuilder() .withBody(message.getBody()) .withAttachments(serviceAttachments) @@ -244,7 +246,8 @@ public class IndividualSendJob extends PushSendJob { .withGiftBadge(giftBadge) .asExpirationUpdate(message.isExpirationUpdate()) .asEndSessionMessage(message.isEndSession()) - .withPayment(payment); + .withPayment(payment) + .withBodyRanges(bodyRanges); if (message.getParentStoryId() != null) { try { @@ -316,12 +319,12 @@ public class IndividualSendJob extends PushSendJob { return new SignalServiceDataMessage.Payment(new SignalServiceDataMessage.PaymentNotification(payment.getReceipt(), payment.getNote()), null); } else { - SignalServiceProtos.DataMessage.Payment.Activation.Type type = null; + DataMessage.Payment.Activation.Type type = null; if (message.isRequestToActivatePayments()) { - type = SignalServiceProtos.DataMessage.Payment.Activation.Type.REQUEST; + type = DataMessage.Payment.Activation.Type.REQUEST; } else if (message.isPaymentsActivated()) { - type = SignalServiceProtos.DataMessage.Payment.Activation.Type.ACTIVATED; + type = DataMessage.Payment.Activation.Type.ACTIVATED; } if (type != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java index 83db83ffd..1e630fa00 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java @@ -39,6 +39,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import java.io.IOException; import java.util.ArrayList; @@ -196,16 +197,17 @@ public final class PushDistributionListSendJob extends PushSendJob { try { rotateSenderCertificateIfNecessary(); - List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); - List attachmentPointers = getAttachmentPointersFor(attachments); - boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId)) - .anyMatch(info -> info.getStatus() > GroupReceiptTable.STATUS_UNDELIVERED); + List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); + List attachmentPointers = getAttachmentPointersFor(attachments); + List bodyRanges = getBodyRanges(message); + boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId)) + .anyMatch(info -> info.getStatus() > GroupReceiptTable.STATUS_UNDELIVERED); final SignalServiceStoryMessage storyMessage; if (message.getStoryType().isTextStory()) { - storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), null, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType().isStoryWithReplies()); + storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), null, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType().isStoryWithReplies(), bodyRanges); } else if (!attachmentPointers.isEmpty()) { - storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), null, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies()); + storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), null, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies(), bodyRanges); } else { throw new UndeliverableMessageException("No attachment on non-text story."); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index d2a627384..0b810cdc8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -14,9 +14,9 @@ import org.signal.core.util.SetUtil; import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.util.Pair; import org.thoughtcrime.securesms.attachments.Attachment; -import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.GroupReceiptTable; import org.thoughtcrime.securesms.database.GroupReceiptTable.GroupReceiptInfo; +import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.RecipientTable; @@ -58,6 +58,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.BodyRange; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2; import java.io.IOException; @@ -247,10 +248,11 @@ public final class PushGroupSendJob extends PushSendJob { List sharedContacts = getSharedContactsFor(message); List previews = getPreviewsFor(message); List mentions = getMentionsFor(message.getMentions()); + List bodyRanges = getBodyRanges(message); List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); List attachmentPointers = getAttachmentPointersFor(attachments); - boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId)) - .anyMatch(info -> info.getStatus() > GroupReceiptTable.STATUS_UNDELIVERED); + boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId)) + .anyMatch(info -> info.getStatus() > GroupReceiptTable.STATUS_UNDELIVERED); if (message.getStoryType().isStory()) { Optional groupRecord = SignalDatabase.groups().getGroup(groupId); @@ -267,10 +269,13 @@ public final class PushGroupSendJob extends PushSendJob { final SignalServiceStoryMessage storyMessage; if (message.getStoryType().isTextStory()) { - storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), groupContext, StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), message.getStoryType() - .isStoryWithReplies()); + storyMessage = SignalServiceStoryMessage.forTextAttachment(Recipient.self().getProfileKey(), + groupContext, + StorySendUtil.deserializeBodyToStoryTextAttachment(message, this::getPreviewsFor), + message.getStoryType().isStoryWithReplies(), + bodyRanges); } else if (!attachmentPointers.isEmpty()) { - storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), groupContext, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies()); + storyMessage = SignalServiceStoryMessage.forFileAttachment(Recipient.self().getProfileKey(), groupContext, attachmentPointers.get(0), message.getStoryType().isStoryWithReplies(), bodyRanges); } else { throw new UndeliverableMessageException("No attachment on non-text story."); } @@ -322,7 +327,8 @@ public final class PushGroupSendJob extends PushSendJob { .withSticker(sticker.orElse(null)) .withSharedContacts(sharedContacts) .withPreviews(previews) - .withMentions(mentions); + .withMentions(mentions) + .withBodyRanges(bodyRanges); if (message.getParentStoryId() != null) { try { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index 73ff34709..38202cdd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.ParentStoryId; import org.thoughtcrime.securesms.database.model.StickerRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.PartProgressEvent; @@ -65,6 +66,7 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException; import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -78,6 +80,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; public abstract class PushSendJob extends SendJob { @@ -296,6 +299,7 @@ public abstract class PushSendJob extends SendJob { String quoteBody = message.getOutgoingQuote().getText(); RecipientId quoteAuthor = message.getOutgoingQuote().getAuthor(); List quoteMentions = getMentionsFor(message.getOutgoingQuote().getMentions()); + List bodyRanges = getBodyRanges(message.getOutgoingQuote().getBodyRanges()); QuoteModel.Type quoteType = message.getOutgoingQuote().getType(); List quoteAttachments = new LinkedList<>(); Optional localQuoteAttachment = message.getOutgoingQuote() @@ -344,9 +348,9 @@ public abstract class PushSendJob extends SendJob { Recipient quoteAuthorRecipient = Recipient.resolved(quoteAuthor); if (quoteAuthorRecipient.isMaybeRegistered()) { - return Optional.of(new SignalServiceDataMessage.Quote(quoteId, RecipientUtil.getOrFetchServiceId(context, quoteAuthorRecipient), quoteBody, quoteAttachments, quoteMentions, quoteType.getDataMessageType())); + return Optional.of(new SignalServiceDataMessage.Quote(quoteId, RecipientUtil.getOrFetchServiceId(context, quoteAuthorRecipient), quoteBody, quoteAttachments, quoteMentions, quoteType.getDataMessageType(), bodyRanges)); } else if (quoteAuthorRecipient.hasServiceId()) { - return Optional.of(new SignalServiceDataMessage.Quote(quoteId, quoteAuthorRecipient.requireServiceId(), quoteBody, quoteAttachments, quoteMentions, quoteType.getDataMessageType())); + return Optional.of(new SignalServiceDataMessage.Quote(quoteId, quoteAuthorRecipient.requireServiceId(), quoteBody, quoteAttachments, quoteMentions, quoteType.getDataMessageType(), bodyRanges)); } else { return Optional.empty(); } @@ -433,6 +437,51 @@ public abstract class PushSendJob extends SendJob { } } + protected @Nullable List getBodyRanges(@NonNull OutgoingMessage message) { + return getBodyRanges(message.getBodyRanges()); + } + + protected @Nullable List getBodyRanges(@Nullable BodyRangeList bodyRanges) { + if (bodyRanges == null || bodyRanges.getRangesCount() == 0) { + return null; + } + + return bodyRanges + .getRangesList() + .stream() + .map(range -> { + SignalServiceProtos.BodyRange.Builder builder = SignalServiceProtos.BodyRange.newBuilder() + .setStart(range.getStart()) + .setLength(range.getLength()); + + if (range.hasStyle()) { + switch (range.getStyle()) { + case BOLD: + builder.setStyle(SignalServiceProtos.BodyRange.Style.BOLD); + break; + case ITALIC: + builder.setStyle(SignalServiceProtos.BodyRange.Style.ITALIC); + break; + case SPOILER: + // Intentionally left blank + break; + case STRIKETHROUGH: + builder.setStyle(SignalServiceProtos.BodyRange.Style.STRIKETHROUGH); + break; + case MONOSPACE: + builder.setStyle(SignalServiceProtos.BodyRange.Style.MONOSPACE); + break; + default: + throw new IllegalArgumentException("Unrecognized style"); + } + } else { + throw new IllegalArgumentException("Only supports style"); + } + + return builder.build(); + }).collect(Collectors.toList()); + } + protected void rotateSenderCertificateIfNecessary() throws IOException { try { Collection requiredCertificateTypes = SignalStore.phoneNumberPrivacy() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt index 0d1e96be9..5c1c0f506 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt @@ -1,13 +1,18 @@ package org.thoughtcrime.securesms.mediasend import android.content.Intent +import android.os.Parcel import android.os.Parcelable +import kotlinx.parcelize.Parceler import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.TypeParceler import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult +import org.thoughtcrime.securesms.util.ParcelUtil /** * A class that lets us nicely format data that we'll send back to [ConversationActivity]. @@ -21,6 +26,7 @@ class MediaSendActivityResult( val messageSendType: MessageSendType, val isViewOnce: Boolean, val mentions: List, + @TypeParceler() val bodyRanges: BodyRangeList?, val storyType: StoryType ) : Parcelable { @@ -40,3 +46,18 @@ class MediaSendActivityResult( } } } + +object BodyRangeListParceler : Parceler { + override fun create(parcel: Parcel): BodyRangeList? { + val data: ByteArray? = ParcelUtil.readByteArray(parcel) + return if (data != null) { + BodyRangeList.parseFrom(data) + } else { + null + } + } + + override fun BodyRangeList?.write(parcel: Parcel, flags: Int) { + ParcelUtil.writeByteArray(parcel, this?.toByteArray()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index 65994d3a8..23147e466 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.StorySend import org.thoughtcrime.securesms.mediasend.CompositeMediaTransform @@ -76,6 +77,7 @@ class MediaSelectionRepository(context: Context) { singleContact: ContactSearchKey.RecipientSearchKey?, contacts: List, mentions: List, + bodyRanges: BodyRangeList?, sendType: MessageSendType ): Maybe { if (isSms && contacts.isNotEmpty()) { @@ -89,9 +91,10 @@ class MediaSelectionRepository(context: Context) { val isSendingToStories = singleContact?.isStory == true || contacts.any { it.isStory } val sentMediaQuality = if (isSendingToStories) SentMediaQuality.STANDARD else quality - return Maybe.create { emitter -> + return Maybe.create { emitter -> val trimmedBody: String = if (isViewOnce) "" else getTruncatedBody(message?.toString()?.trim()) ?: "" val trimmedMentions: List = if (isViewOnce) emptyList() else mentions + val trimmedBodyRanges: BodyRangeList? = if (isViewOnce) null else bodyRanges val modelsToTransform: Map = buildModelsToTransform(selectedMedia, stateMap, sentMediaQuality) val oldToNewMediaMap: Map = MediaRepository.transformMediaSync(context, selectedMedia, modelsToTransform) val updatedMedia = oldToNewMediaMap.values.toList() @@ -119,6 +122,7 @@ class MediaSelectionRepository(context: Context) { messageSendType = sendType, isViewOnce = isViewOnce, mentions = trimmedMentions, + bodyRanges = trimmedBodyRanges, storyType = StoryType.NONE ) ) @@ -154,7 +158,7 @@ class MediaSelectionRepository(context: Context) { uploadRepository.updateDisplayOrder(updatedMedia) uploadRepository.getPreUploadResults { uploadResults -> if (contacts.isNotEmpty()) { - sendMessages(contacts, splitBody, uploadResults, trimmedMentions, isViewOnce, clippedVideosForStories) + sendMessages(contacts, splitBody, uploadResults, trimmedMentions, trimmedBodyRanges, isViewOnce, clippedVideosForStories) uploadRepository.deleteAbandonedAttachments() emitter.onComplete() } else if (uploadResults.isNotEmpty()) { @@ -166,6 +170,7 @@ class MediaSelectionRepository(context: Context) { messageSendType = sendType, isViewOnce = isViewOnce, mentions = trimmedMentions, + bodyRanges = trimmedBodyRanges, storyType = storyType ) ) @@ -179,6 +184,7 @@ class MediaSelectionRepository(context: Context) { messageSendType = sendType, isViewOnce = isViewOnce, mentions = trimmedMentions, + bodyRanges = trimmedBodyRanges, storyType = storyType ) ) @@ -256,6 +262,7 @@ class MediaSelectionRepository(context: Context) { body: String, preUploadResults: Collection, mentions: List, + bodyRanges: BodyRangeList?, isViewOnce: Boolean, storyClips: List ) { @@ -287,6 +294,7 @@ class MediaSelectionRepository(context: Context) { isViewOnce = isViewOnce, storyType = storyType, mentions = mentions, + bodyRanges = bodyRanges, isSecure = true ) @@ -317,6 +325,7 @@ class MediaSelectionRepository(context: Context) { isViewOnce = isViewOnce, storyType = storyType, mentions = mentions, + bodyRanges = bodyRanges, isSecure = true ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index cf9f40607..84da9f5a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -22,6 +22,7 @@ import org.signal.core.util.BreakIteratorCompat import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType +import org.thoughtcrime.securesms.conversation.MessageStyler import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.VideoEditorFragment @@ -336,16 +337,17 @@ class MediaSelectionViewModel( ): Maybe { return UntrustedRecords.checkForBadIdentityRecords(selectedContacts.toSet(), identityChangesSince).andThen( repository.send( - store.state.selectedMedia, - store.state.editorStateMap, - store.state.quality, - store.state.message, - store.state.sendType.usesSmsTransport, - isViewOnceEnabled(), - destination.getRecipientSearchKey(), - selectedContacts.ifEmpty { destination.getRecipientSearchKeyList() }, - MentionAnnotation.getMentionsFromAnnotations(store.state.message), - store.state.sendType + selectedMedia = store.state.selectedMedia, + stateMap = store.state.editorStateMap, + quality = store.state.quality, + message = store.state.message, + isSms = store.state.sendType.usesSmsTransport, + isViewOnce = isViewOnceEnabled(), + singleContact = destination.getRecipientSearchKey(), + contacts = selectedContacts.ifEmpty { destination.getRecipientSearchKeyList() }, + mentions = MentionAnnotation.getMentionsFromAnnotations(store.state.message), + bodyRanges = MessageStyler.getStyling(store.state.message), + sendType = store.state.sendType ) ) } 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 8d0bcd2ed..2497a29da 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.database.SentStorySyncManifest; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.StickerTable; import org.thoughtcrime.securesms.database.ThreadTable; +import org.thoughtcrime.securesms.database.model.DatabaseProtosUtil; import org.thoughtcrime.securesms.database.model.DistributionListId; import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; @@ -62,6 +63,7 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.StickerRecord; import org.thoughtcrime.securesms.database.model.StoryType; import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor; import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge; import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost; @@ -170,6 +172,7 @@ import org.whispersystems.signalservice.api.payments.Money; import org.whispersystems.signalservice.api.push.DistributionId; import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.SignalServiceAddress; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage; import java.io.IOException; @@ -274,7 +277,7 @@ public final class MessageContentProcessor { if (content.getDataMessage().isPresent()) { GroupTable groupDatabase = SignalDatabase.groups(); SignalServiceDataMessage message = content.getDataMessage().get(); - boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent() || message.getMentions().isPresent(); + boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent() || message.getMentions().isPresent() || message.getBodyRanges().isPresent(); Optional groupId = GroupUtil.idFromGroupContext(message.getGroupContext()); boolean isGv2Message = groupId.isPresent() && groupId.get().isV2(); @@ -1550,7 +1553,8 @@ public final class MessageContentProcessor { content.getServerUuid(), null, false, - false); + false, + getBodyRangeList(message.getBodyRanges())); insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -1671,13 +1675,15 @@ public final class MessageContentProcessor { } else if (SignalDatabase.storySends().canReply(senderRecipient.getId(), storyContext.getSentTimestamp())) { MmsMessageRecord story = (MmsMessageRecord) database.getMessageRecord(storyMessageId.getId()); - String displayText = ""; + String displayText = ""; + BodyRangeList bodyRanges = null; if (story.getStoryType().isTextStory()) { displayText = story.getBody(); + bodyRanges = story.getMessageRanges(); } parentStoryId = new ParentStoryId.DirectReply(storyMessageId.getId()); - quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, displayText, false, story.getSlideDeck().asAttachments(), Collections.emptyList(), QuoteModel.Type.NORMAL); + quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, displayText, false, story.getSlideDeck().asAttachments(), Collections.emptyList(), QuoteModel.Type.NORMAL, bodyRanges); expiresInMillis = TimeUnit.SECONDS.toMillis(message.getExpiresInSeconds()); } else { warn(content.getTimestamp(), "Story has reactions disabled. Dropping reaction."); @@ -1780,12 +1786,14 @@ public final class MessageContentProcessor { } else if (groupStory || SignalDatabase.storySends().canReply(senderRecipient.getId(), storyContext.getSentTimestamp())) { parentStoryId = new ParentStoryId.DirectReply(storyMessageId.getId()); - String displayText = ""; + String displayText = ""; + BodyRangeList bodyRanges = null; if (story.getStoryType().isTextStory()) { displayText = story.getBody(); + bodyRanges = story.getMessageRanges(); } - quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, displayText, false, story.getSlideDeck().asAttachments(), Collections.emptyList(), QuoteModel.Type.NORMAL); + quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, displayText, false, story.getSlideDeck().asAttachments(), Collections.emptyList(), QuoteModel.Type.NORMAL, bodyRanges); expiresInMillis = TimeUnit.SECONDS.toMillis(message.getExpiresInSeconds()); } else { warn(content.getTimestamp(), "Story has replies disabled. Dropping reply."); @@ -1796,6 +1804,8 @@ public final class MessageContentProcessor { return null; } + BodyRangeList bodyRanges = message.getBodyRanges().map(DatabaseProtosUtil::toBodyRangeList).orElse(null); + IncomingMediaMessage mediaMessage = new IncomingMediaMessage(senderRecipient.getId(), content.getTimestamp(), content.getServerReceivedTimestamp(), @@ -1819,7 +1829,8 @@ public final class MessageContentProcessor { content.getServerUuid(), null, false, - false); + false, + bodyRanges); Optional insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -1934,6 +1945,7 @@ public final class MessageContentProcessor { Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().orElse(""), false); Optional> mentions = getMentions(message.getMentions()); Optional sticker = getStickerAttachment(message.getSticker()); + BodyRangeList messageRanges = getBodyRangeList(message.getBodyRanges()); handlePossibleExpirationUpdate(content, message, Optional.empty(), senderRecipient, threadRecipient, receivedTime); @@ -1960,7 +1972,8 @@ public final class MessageContentProcessor { content.getServerUuid(), null, false, - false); + false, + messageRanges); insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -2064,12 +2077,15 @@ public final class MessageContentProcessor { } else if (groupStory || story.getStoryType().isStoryWithReplies()) { parentStoryId = new ParentStoryId.DirectReply(storyMessageId.getId()); - String quoteBody = ""; + String quoteBody = ""; + BodyRangeList bodyRanges = null; + if (story.getStoryType().isTextStory()) { - quoteBody = story.getBody(); + quoteBody = story.getBody(); + bodyRanges = story.getMessageRanges(); } - quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, quoteBody, false, story.getSlideDeck().asAttachments(), Collections.emptyList(), QuoteModel.Type.NORMAL); + quoteModel = new QuoteModel(storyContext.getSentTimestamp(), storyAuthorRecipient, quoteBody, false, story.getSlideDeck().asAttachments(), Collections.emptyList(), QuoteModel.Type.NORMAL, bodyRanges); expiresInMillis = TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds()); } else { warn(envelopeTimestamp, "Story has replies disabled. Dropping reply."); @@ -2094,7 +2110,8 @@ public final class MessageContentProcessor { Collections.emptySet(), Collections.emptySet(), null, - true); + true, + null); if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { handleSynchronizeSentExpirationUpdate(message); @@ -2213,7 +2230,8 @@ public final class MessageContentProcessor { Collections.emptySet(), Collections.emptySet(), null, - true); + true, + null); MessageTable messageTable = SignalDatabase.messages(); long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); @@ -2310,7 +2328,8 @@ public final class MessageContentProcessor { Collections.emptySet(), Collections.emptySet(), giftBadge.orElse(null), - true); + true, + null); if (recipients.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { handleSynchronizeSentExpirationUpdate(message); @@ -2488,9 +2507,10 @@ public final class MessageContentProcessor { { log(envelopeTimestamp, "Synchronize sent text message for " + message.getTimestamp()); - Recipient recipient = getSyncMessageDestination(message); - String body = message.getDataMessage().get().getBody().orElse(""); - long expiresInMillis = TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds()); + Recipient recipient = getSyncMessageDestination(message); + String body = message.getDataMessage().get().getBody().orElse(""); + long expiresInMillis = TimeUnit.SECONDS.toMillis(message.getDataMessage().get().getExpiresInSeconds()); + BodyRangeList bodyRanges = message.getDataMessage().get().getBodyRanges().map(DatabaseProtosUtil::toBodyRangeList).orElse(null); if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { handleSynchronizeSentExpirationUpdate(message); @@ -2513,14 +2533,15 @@ public final class MessageContentProcessor { StoryType.NONE, Collections.emptyList(), Collections.emptyList(), - true); + true, + bodyRanges); messageId = SignalDatabase.messages().insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null); database = SignalDatabase.messages(); updateGroupReceiptStatus(message, messageId, recipient.requireGroupId()); } else { - OutgoingMessage outgoingTextMessage = OutgoingMessage.text(recipient, body, expiresInMillis, message.getTimestamp()); + OutgoingMessage outgoingTextMessage = OutgoingMessage.text(recipient, body, expiresInMillis, message.getTimestamp(), bodyRanges); messageId = SignalDatabase.messages().insertMessageOutbox(outgoingTextMessage, threadId, false, null); database = SignalDatabase.messages(); @@ -3023,7 +3044,7 @@ public final class MessageContentProcessor { String body = message.isPaymentNotification() ? message.getDisplayBody(context).toString() : message.getBody(); - return Optional.of(new QuoteModel(quote.get().getId(), author, body, false, attachments, mentions, QuoteModel.Type.fromDataMessageType(quote.get().getType()))); + return Optional.of(new QuoteModel(quote.get().getId(), author, body, false, attachments, mentions, QuoteModel.Type.fromDataMessageType(quote.get().getType()), message.getMessageRanges())); } else if (message != null) { warn("Found the target for the quote, but it's flagged as remotely deleted."); } @@ -3036,7 +3057,8 @@ public final class MessageContentProcessor { true, PointerAttachment.forPointers(quote.get().getAttachments()), getMentions(quote.get().getMentions()), - QuoteModel.Type.fromDataMessageType(quote.get().getType()))); + QuoteModel.Type.fromDataMessageType(quote.get().getType()), + DatabaseProtosUtil.toBodyRangeList(quote.get().getBodyRanges()))); } private Optional getStickerAttachment(Optional sticker) { @@ -3124,6 +3146,14 @@ public final class MessageContentProcessor { return Optional.of(getMentions(signalServiceMentions.get())); } + private @Nullable BodyRangeList getBodyRangeList(Optional> bodyRanges) { + if (!bodyRanges.isPresent()) { + return null; + } + + return DatabaseProtosUtil.toBodyRangeList(bodyRanges.get()); + } + private @NonNull List getMentions(@Nullable List signalServiceMentions) { if (signalServiceMentions == null || signalServiceMentions.isEmpty()) { return Collections.emptyList(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt index a1530c1c4..388fe51d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.kt @@ -90,6 +90,7 @@ class IncomingMediaMessage( isPaymentsActivated = paymentsActivated ) + @JvmOverloads constructor( from: RecipientId?, sentTimeMillis: Long, @@ -114,7 +115,8 @@ class IncomingMediaMessage( serverGuid: String?, giftBadge: GiftBadge?, activatePaymentsRequest: Boolean, - paymentsActivated: Boolean + paymentsActivated: Boolean, + messageRanges: BodyRangeList? = null ) : this( from = from, groupId = if (group.isPresent) GroupId.v2(group.get().masterKey) else null, @@ -139,7 +141,8 @@ class IncomingMediaMessage( mentions = mentions.orElse(emptyList()), giftBadge = giftBadge, isActivatePaymentsRequest = activatePaymentsRequest, - isPaymentsActivated = paymentsActivated + isPaymentsActivated = paymentsActivated, + messageRanges = messageRanges ) companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt index cbfd25c57..59dfc601f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailure import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.ParentStoryId import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge import org.thoughtcrime.securesms.linkpreview.LinkPreview @@ -34,6 +35,7 @@ data class OutgoingMessage( val attachments: List = emptyList(), val sharedContacts: List = emptyList(), val linkPreviews: List = emptyList(), + val bodyRanges: BodyRangeList? = null, val mentions: List = emptyList(), val isGroup: Boolean = false, val isGroupUpdate: Boolean = false, @@ -75,7 +77,8 @@ data class OutgoingMessage( networkFailures: Set = emptySet(), mismatches: Set = emptySet(), giftBadge: GiftBadge? = null, - isSecure: Boolean = false + isSecure: Boolean = false, + bodyRanges: BodyRangeList? = null ) : this( recipient = recipient, body = body ?: "", @@ -95,7 +98,8 @@ data class OutgoingMessage( networkFailures = networkFailures, identityKeyMismatches = mismatches, giftBadge = giftBadge, - isSecure = isSecure + isSecure = isSecure, + bodyRanges = bodyRanges ) /** @@ -112,7 +116,8 @@ data class OutgoingMessage( storyType: StoryType = StoryType.NONE, linkPreviews: List = emptyList(), mentions: List = emptyList(), - isSecure: Boolean = false + isSecure: Boolean = false, + bodyRanges: BodyRangeList? = null ) : this( recipient = recipient, body = buildMessage(slideDeck, body ?: ""), @@ -124,7 +129,8 @@ data class OutgoingMessage( storyType = storyType, linkPreviews = linkPreviews, mentions = mentions, - isSecure = isSecure + isSecure = isSecure, + bodyRanges = bodyRanges ) fun withExpiry(expiresIn: Long): OutgoingMessage { @@ -167,14 +173,21 @@ data class OutgoingMessage( * A secure message that only contains text. */ @JvmStatic - fun text(recipient: Recipient, body: String, expiresIn: Long, sentTimeMillis: Long = System.currentTimeMillis()): OutgoingMessage { + fun text( + recipient: Recipient, + body: String, + expiresIn: Long, + sentTimeMillis: Long = System.currentTimeMillis(), + bodyRanges: BodyRangeList? = null + ): OutgoingMessage { return OutgoingMessage( recipient = recipient, sentTimeMillis = sentTimeMillis, body = body, expiresIn = expiresIn, isUrgent = true, - isSecure = true + isSecure = true, + bodyRanges = bodyRanges ) } @@ -239,7 +252,8 @@ data class OutgoingMessage( body: String, sentTimeMillis: Long, storyType: StoryType, - linkPreviews: List + linkPreviews: List, + bodyRanges: BodyRangeList? ): OutgoingMessage { return OutgoingMessage( recipient = recipient, @@ -247,6 +261,7 @@ data class OutgoingMessage( sentTimeMillis = sentTimeMillis, storyType = storyType, linkPreviews = linkPreviews, + bodyRanges = bodyRanges, isSecure = true ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java b/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java index e10424209..d6d636e65 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.recipients.RecipientId; import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; @@ -21,6 +22,7 @@ public class QuoteModel { private final List attachments; private final List mentions; private final Type type; + private final BodyRangeList bodyRanges; public QuoteModel(long id, @NonNull RecipientId author, @@ -28,7 +30,8 @@ public class QuoteModel { boolean missing, @Nullable List attachments, @Nullable List mentions, - @NonNull Type type) + @NonNull Type type, + @Nullable BodyRangeList bodyRanges) { this.id = id; this.author = author; @@ -37,6 +40,7 @@ public class QuoteModel { this.attachments = attachments; this.mentions = mentions != null ? mentions : Collections.emptyList(); this.type = type; + this.bodyRanges = bodyRanges; } public long getId() { @@ -67,6 +71,10 @@ public class QuoteModel { return type; } + public @Nullable BodyRangeList getBodyRanges() { + return bodyRanges; + } + public enum Type { NORMAL(0, SignalServiceDataMessage.Quote.Type.NORMAL), GIFT_BADGE(1, SignalServiceDataMessage.Quote.Type.GIFT_BADGE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index e3be42a27..d8b584047 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -100,12 +100,13 @@ public class RemoteReplyReceiver extends BroadcastReceiver { Collections.emptySet(), Collections.emptySet(), null, - recipient.isPushGroup()); + recipient.isPushGroup(), + null); threadId = MessageSender.send(context, reply, -1, MessageSender.SendType.SIGNAL, null, null); break; } case SecureMessage: { - OutgoingMessage reply = OutgoingMessage.text(recipient, responseText.toString(), expiresIn, System.currentTimeMillis()); + OutgoingMessage reply = OutgoingMessage.text(recipient, responseText.toString(), expiresIn, System.currentTimeMillis(), null); threadId = MessageSender.send(context, reply, -1, MessageSender.SendType.SIGNAL, null, null); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt index a14ca4b57..98b553ab9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationItem.kt @@ -193,17 +193,17 @@ class MessageNotification(threadRecipient: Recipient, record: MessageRecord) : N } else if (record.isRemoteDelete) { SpanUtil.italic(context.getString(R.string.MessageNotifier_this_message_was_deleted)) } else if (record.isMms && !record.isMmsNotification && (record as MmsMessageRecord).slideDeck.slides.isNotEmpty()) { - ThreadBodyUtil.getFormattedBodyFor(context, record) + ThreadBodyUtil.getFormattedBodyFor(context, record).body } else if (record.isGroupCall) { MessageRecord.getGroupCallUpdateDescription(context, record.body, false).spannable } else if (record.hasGiftBadge()) { - ThreadBodyUtil.getFormattedBodyFor(context, record) + ThreadBodyUtil.getFormattedBodyFor(context, record).body } else if (record.isStoryReaction()) { - ThreadBodyUtil.getFormattedBodyFor(context, record) + ThreadBodyUtil.getFormattedBodyFor(context, record).body } else if (record.isPaymentNotification()) { - ThreadBodyUtil.getFormattedBodyFor(context, record) + ThreadBodyUtil.getFormattedBodyFor(context, record).body } else { - MentionUtil.updateBodyWithDisplayNames(context, record) + MentionUtil.updateBodyWithDisplayNames(context, record) ?: "" } } @@ -299,7 +299,7 @@ class ReactionNotification(threadRecipient: Recipient, record: MessageRecord, va } private fun getReactionMessageBody(context: Context): CharSequence { - val body: CharSequence = MentionUtil.updateBodyWithDisplayNames(context, record) + val body: CharSequence = MentionUtil.updateBodyWithDisplayNames(context, record) ?: "" val bodyIsEmpty: Boolean = TextUtils.isEmpty(body) return if (record.hasSharedContact()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/MessageResult.kt b/app/src/main/java/org/thoughtcrime/securesms/search/MessageResult.kt index d5bb1c381..2749bbabe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/MessageResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/search/MessageResult.kt @@ -8,8 +8,8 @@ import org.thoughtcrime.securesms.recipients.Recipient data class MessageResult( val conversationRecipient: Recipient, val messageRecipient: Recipient, - val body: String, - val bodySnippet: String, + val body: CharSequence, + val bodySnippet: CharSequence, val threadId: Long, val messageId: Long, val receivedTimestampMs: Long, diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index 091f24f31..74bdc14eb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -3,6 +3,9 @@ package org.thoughtcrime.securesms.search; import android.content.Context; import android.database.Cursor; import android.database.MergeCursor; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -12,10 +15,14 @@ import androidx.annotation.WorkerThread; import com.annimon.stream.Stream; import org.signal.core.util.CursorUtil; +import org.signal.core.util.StringUtil; import org.signal.core.util.concurrent.LatestPrioritizedSerialExecutor; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.contacts.ContactRepository; +import org.thoughtcrime.securesms.conversation.MessageStyler; +import org.thoughtcrime.securesms.database.BodyAdjustment; +import org.thoughtcrime.securesms.database.BodyRangeUtil; import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.MentionTable; import org.thoughtcrime.securesms.database.MentionUtil; @@ -28,6 +35,7 @@ import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.ThreadRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -43,6 +51,7 @@ import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.Executor; import java.util.function.Consumer; @@ -62,8 +71,8 @@ public class SearchRepository { private final ContactRepository contactRepository; private final ThreadTable threadTable; private final RecipientTable recipientTable; - private final MentionTable mentionDatabase; - private final MessageTable mmsDatabase; + private final MentionTable mentionTable; + private final MessageTable messageTable; private final LatestPrioritizedSerialExecutor searchExecutor; private final Executor serialExecutor; @@ -71,11 +80,11 @@ public class SearchRepository { public SearchRepository(@NonNull String noteToSelfTitle) { this.context = ApplicationDependencies.getApplication().getApplicationContext(); this.noteToSelfTitle = noteToSelfTitle; - this.searchDatabase = SignalDatabase.messageSearch(); - this.threadTable = SignalDatabase.threads(); - this.recipientTable = SignalDatabase.recipients(); - this.mentionDatabase = SignalDatabase.mentions(); - this.mmsDatabase = SignalDatabase.messages(); + this.searchDatabase = SignalDatabase.messageSearch(); + this.threadTable = SignalDatabase.threads(); + this.recipientTable = SignalDatabase.recipients(); + this.mentionTable = SignalDatabase.mentions(); + this.messageTable = SignalDatabase.messages(); this.contactRepository = new ContactRepository(context, noteToSelfTitle); this.searchExecutor = new LatestPrioritizedSerialExecutor(SignalExecutors.BOUNDED); this.serialExecutor = new SerialExecutor(SignalExecutors.BOUNDED); @@ -144,7 +153,7 @@ public class SearchRepository { Cursor textSecureContacts = contactRepository.querySignalContacts(query); Cursor systemContacts = contactRepository.queryNonSignalContacts(query); - contacts = new MergeCursor(new Cursor[]{ textSecureContacts, systemContacts }); + contacts = new MergeCursor(new Cursor[] { textSecureContacts, systemContacts }); return readToList(contacts, new RecipientModelBuilder(), 250); } finally { @@ -225,21 +234,44 @@ public class SearchRepository { return results; } - Map> mentions = SignalDatabase.mentions().getMentionsForMessages(messageIds); - if (mentions.isEmpty()) { + Map bodyRanges = SignalDatabase.messages().getBodyRangesForMessages(messageIds); + Map> mentions = SignalDatabase.mentions().getMentionsForMessages(messageIds); + + if (bodyRanges.isEmpty() && mentions.isEmpty()) { return results; } List updatedResults = new ArrayList<>(results.size()); for (MessageResult result : results) { - if (result.isMms() && mentions.containsKey(result.getMessageId())) { - List messageMentions = mentions.get(result.getMessageId()); + if (bodyRanges.containsKey(result.getMessageId()) || mentions.containsKey(result.getMessageId())) { + CharSequence body = result.getBody(); + CharSequence bodySnippet = result.getBodySnippet(); + CharSequence updatedBody = body; + List bodyAdjustments = Collections.emptyList(); + CharSequence updatedSnippet = bodySnippet; + List snippetAdjustments = Collections.emptyList(); + List messageMentions = mentions.get(result.getMessageId()); + BodyRangeList ranges = bodyRanges.get(result.getMessageId()); - //noinspection ConstantConditions - String updatedBody = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, result.getBody(), messageMentions).getBody().toString(); - String updatedSnippet = updateSnippetWithDisplayNames(result.getBody(), result.getBodySnippet(), messageMentions); + if (messageMentions != null) { + MentionUtil.UpdatedBodyAndMentions bodyMentionUpdate = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, messageMentions); + updatedBody = Objects.requireNonNull(bodyMentionUpdate.getBody()); + bodyAdjustments = bodyMentionUpdate.getBodyAdjustments(); + + MentionUtil.UpdatedBodyAndMentions snippetMentionUpdate = updateSnippetWithDisplayNames(body, bodySnippet, messageMentions); + updatedSnippet = Objects.requireNonNull(snippetMentionUpdate.getBody()); + snippetAdjustments = snippetMentionUpdate.getBodyAdjustments(); + } + + if (ranges != null) { + updatedBody = SpannableString.valueOf(updatedBody); + MessageStyler.style(BodyRangeUtil.adjustBodyRanges(ranges, bodyAdjustments), (Spannable) updatedBody); + + updatedSnippet = SpannableString.valueOf(updatedSnippet); + //noinspection ConstantConditions + updateSnippetWithStyles(updatedBody, (SpannableString) updatedSnippet, BodyRangeUtil.adjustBodyRanges(ranges, snippetAdjustments)); + } - //noinspection ConstantConditions updatedResults.add(new MessageResult(result.getConversationRecipient(), result.getMessageRecipient(), updatedBody, updatedSnippet, result.getThreadId(), result.getMessageId(), result.getReceivedTimestampMs(), result.isMms())); } else { updatedResults.add(result); @@ -249,20 +281,20 @@ public class SearchRepository { return updatedResults; } - private @NonNull String updateSnippetWithDisplayNames(@NonNull String body, @NonNull String bodySnippet, @NonNull List mentions) { - String cleanSnippet = bodySnippet; - int startOffset = 0; + private @NonNull MentionUtil.UpdatedBodyAndMentions updateSnippetWithDisplayNames(@NonNull CharSequence body, @NonNull CharSequence bodySnippet, @NonNull List mentions) { + CharSequence cleanSnippet = bodySnippet; + int startOffset = 0; - if (cleanSnippet.startsWith(SNIPPET_WRAP)) { - cleanSnippet = cleanSnippet.substring(SNIPPET_WRAP.length()); + if (StringUtil.startsWith(cleanSnippet, SNIPPET_WRAP)) { + cleanSnippet = cleanSnippet.subSequence(SNIPPET_WRAP.length(), cleanSnippet.length()); startOffset = SNIPPET_WRAP.length(); } - if (cleanSnippet.endsWith(SNIPPET_WRAP)) { - cleanSnippet = cleanSnippet.substring(0, cleanSnippet.length() - SNIPPET_WRAP.length()); + if (StringUtil.endsWith(cleanSnippet, SNIPPET_WRAP)) { + cleanSnippet = cleanSnippet.subSequence(0, cleanSnippet.length() - SNIPPET_WRAP.length()); } - int startIndex = body.indexOf(cleanSnippet); + int startIndex = TextUtils.indexOf(body, cleanSnippet); if (startIndex != -1) { List adjustMentions = new ArrayList<>(mentions.size()); @@ -273,11 +305,38 @@ public class SearchRepository { } } - //noinspection ConstantConditions - return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, bodySnippet, adjustMentions).getBody().toString(); + return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, bodySnippet, adjustMentions); + } else { + return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, bodySnippet, Collections.emptyList()); + } + } + + private void updateSnippetWithStyles(@NonNull CharSequence body, @NonNull SpannableString bodySnippet, @NonNull BodyRangeList bodyRanges) { + CharSequence cleanSnippet = bodySnippet; + int startOffset = 0; + + if (StringUtil.startsWith(cleanSnippet, SNIPPET_WRAP)) { + cleanSnippet = cleanSnippet.subSequence(SNIPPET_WRAP.length(), cleanSnippet.length()); + startOffset = SNIPPET_WRAP.length(); } - return bodySnippet; + if (StringUtil.endsWith(cleanSnippet, SNIPPET_WRAP)) { + cleanSnippet = cleanSnippet.subSequence(0, cleanSnippet.length() - SNIPPET_WRAP.length()); + } + + int startIndex = TextUtils.indexOf(body, cleanSnippet); + + if (startIndex != -1) { + BodyRangeList.Builder builder = BodyRangeList.newBuilder(); + for (BodyRangeList.BodyRange range : bodyRanges.getRangesList()) { + int adjustedStart = range.getStart() - startIndex + startOffset; + if (adjustedStart >= 0 && adjustedStart + range.getLength() <= cleanSnippet.length()) { + builder.addRanges(range.toBuilder().setStart(adjustedStart).build()); + } + } + + MessageStyler.style(builder.build(), bodySnippet); + } } private @NonNull List queryMessages(@NonNull String query, long threadId) { @@ -294,7 +353,7 @@ public class SearchRepository { } } - Map> mentionQueryResults = mentionDatabase.getMentionsContainingRecipients(recipientIds, 500); + Map> mentionQueryResults = mentionTable.getMentionsContainingRecipients(recipientIds, 500); if (mentionQueryResults.isEmpty()) { return Collections.emptyList(); @@ -302,14 +361,20 @@ public class SearchRepository { List results = new ArrayList<>(); - try (MessageTable.Reader reader = mmsDatabase.getMessages(mentionQueryResults.keySet())) { - MessageRecord record; - while ((record = reader.getNext()) != null) { - List mentions = mentionQueryResults.get(record.getId()); + try (MessageTable.Reader reader = messageTable.getMessages(mentionQueryResults.keySet())) { + for (MessageRecord record : reader) { + BodyRangeList bodyRanges = record.getMessageRanges(); + List mentions = mentionQueryResults.get(record.getId()); + if (Util.hasItems(mentions)) { - MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, record.getBody(), mentions); - String updatedBody = updated.getBody() != null ? updated.getBody().toString() : record.getBody(); - String updatedSnippet = makeSnippet(cleanQueries, updatedBody); + SpannableString body = new SpannableString(record.getBody()); + + if (bodyRanges != null) { + MessageStyler.style(bodyRanges, body); + } + + CharSequence updatedBody = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions).getBody(); + CharSequence updatedSnippet = makeSnippet(cleanQueries, Objects.requireNonNull(updatedBody)); //noinspection ConstantConditions results.add(new MessageResult(threadTable.getRecipientForThreadId(record.getThreadId()), record.getRecipient(), updatedBody, updatedSnippet, record.getThreadId(), record.getId(), record.getDateReceived(), true)); @@ -328,7 +393,7 @@ public class SearchRepository { } } - Map> mentionQueryResults = mentionDatabase.getMentionsContainingRecipients(recipientIds, threadId, 500); + Map> mentionQueryResults = mentionTable.getMentionsContainingRecipients(recipientIds, threadId, 500); if (mentionQueryResults.isEmpty()) { return Collections.emptyList(); @@ -336,9 +401,8 @@ public class SearchRepository { List results = new ArrayList<>(); - try (MessageTable.Reader reader = mmsDatabase.getMessages(mentionQueryResults.keySet())) { - MessageRecord record; - while ((record = reader.getNext()) != null) { + try (MessageTable.Reader reader = messageTable.getMessages(mentionQueryResults.keySet())) { + for (MessageRecord record : reader) { //noinspection ConstantConditions results.add(new MessageResult(threadTable.getRecipientForThreadId(record.getThreadId()), record.getRecipient(), record.getBody(), record.getBody(), record.getThreadId(), record.getId(), record.getDateReceived(), true)); } @@ -347,23 +411,26 @@ public class SearchRepository { return results; } - private @NonNull String makeSnippet(@NonNull List queries, @NonNull String body) { - if (body.length() < 50) { - return body; + private @NonNull CharSequence makeSnippet(@NonNull List queries, @NonNull CharSequence styledBody) { + if (styledBody.length() < 50) { + return styledBody; } - String lowerBody = body.toLowerCase(); + String lowerBody = styledBody.toString().toLowerCase(); for (String query : queries) { int foundIndex = lowerBody.indexOf(query.toLowerCase()); if (foundIndex != -1) { - int snippetStart = Math.max(0, Math.max(body.lastIndexOf(' ', foundIndex - 5) + 1, foundIndex - 15)); - int lastSpace = body.indexOf(' ', foundIndex + 30); - int snippetEnd = Math.min(body.length(), lastSpace > 0 ? Math.min(lastSpace, foundIndex + 40) : foundIndex + 40); + int snippetStart = Math.max(0, Math.max(lowerBody.lastIndexOf(' ', foundIndex - 5) + 1, foundIndex - 15)); + int lastSpace = lowerBody.indexOf(' ', foundIndex + 30); + int snippetEnd = Math.min(lowerBody.length(), lastSpace > 0 ? Math.min(lastSpace, foundIndex + 40) : foundIndex + 40); - return (snippetStart > 0 ? SNIPPET_WRAP : "") + body.substring(snippetStart, snippetEnd) + (snippetEnd < body.length() ? SNIPPET_WRAP : ""); + return new SpannableStringBuilder().append(snippetStart > 0 ? SNIPPET_WRAP : "") + .append(styledBody.subSequence(snippetStart, snippetEnd)) + .append(snippetEnd < styledBody.length() ? SNIPPET_WRAP : ""); } } - return body; + + return styledBody; } private @NonNull List readToList(@Nullable Cursor cursor, @NonNull ModelBuilder builder) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java index 8e0f54d49..5f7a896fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareArgs.java @@ -9,10 +9,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.annimon.stream.Stream; +import com.google.protobuf.InvalidProtocolBufferException; import org.signal.core.util.BreakIteratorCompat; +import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.mediasend.Media; import org.thoughtcrime.securesms.stickers.StickerLocator; @@ -31,6 +34,8 @@ import java.util.stream.Collectors; public final class MultiShareArgs implements Parcelable { + private static final String TAG = Log.tag(MultiShareArgs.class); + private final Set contactSearchKeys; private final List media; private final String draftText; @@ -44,6 +49,7 @@ public final class MultiShareArgs implements Parcelable { private final long timestamp; private final long expiresAt; private final boolean isTextStory; + private final BodyRangeList bodyRanges; private MultiShareArgs(@NonNull Builder builder) { contactSearchKeys = builder.contactSearchKeys; @@ -59,6 +65,7 @@ public final class MultiShareArgs implements Parcelable { timestamp = builder.timestamp; expiresAt = builder.expiresAt; isTextStory = builder.isTextStory; + bodyRanges = builder.bodyRanges; } protected MultiShareArgs(Parcel in) { @@ -86,6 +93,17 @@ public final class MultiShareArgs implements Parcelable { } linkPreview = preview; + + BodyRangeList bodyRanges = null; + try { + byte[] data = ParcelUtil.readByteArray(in); + if (data != null) { + bodyRanges = BodyRangeList.parseFrom(data); + } + } catch (InvalidProtocolBufferException e) { + Log.w(TAG, "Invalid body range", e); + } + this.bodyRanges = bodyRanges; } public Set getContactSearchKeys() { @@ -147,6 +165,10 @@ public final class MultiShareArgs implements Parcelable { return expiresAt; } + public @Nullable BodyRangeList getBodyRanges() { + return bodyRanges; + } + public boolean isValidForStories() { if (isViewOnce()) { return false; @@ -241,6 +263,12 @@ public final class MultiShareArgs implements Parcelable { } else { dest.writeString(""); } + + if (bodyRanges != null) { + ParcelUtil.writeByteArray(dest, bodyRanges.toByteArray()); + } else { + ParcelUtil.writeByteArray(dest, null); + } } public Builder buildUpon() { @@ -259,7 +287,8 @@ public final class MultiShareArgs implements Parcelable { .withMentions(mentions) .withTimestamp(timestamp) .withExpiration(expiresAt) - .asTextStory(isTextStory); + .asTextStory(isTextStory) + .withBodyRanges(bodyRanges); } private boolean requiresInterstitial() { @@ -286,6 +315,7 @@ public final class MultiShareArgs implements Parcelable { private long timestamp; private long expiresAt; private boolean isTextStory; + private BodyRangeList bodyRanges; public Builder() { this(Collections.emptySet()); @@ -355,6 +385,11 @@ public final class MultiShareArgs implements Parcelable { return this; } + public @NonNull Builder withBodyRanges(@Nullable BodyRangeList bodyRanges) { + this.bodyRanges = bodyRanges; + return this; + } + public @NonNull MultiShareArgs build() { return new MultiShareArgs(this); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java index 18273a1bd..3b0aa3628 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/MultiShareSender.java @@ -250,7 +250,8 @@ public final class MultiShareSender { storyType.toTextStoryType(), buildLinkPreviews(context, multiShareArgs.getLinkPreview()), Collections.emptyList(), - false); + false, + multiShareArgs.getBodyRanges()); outgoingMessages.add(outgoingMessage); } else if (canSendAsTextStory) { @@ -286,7 +287,8 @@ public final class MultiShareSender { storyType, Collections.emptyList(), validatedMentions, - false); + false, + multiShareArgs.getBodyRanges()); outgoingMessages.add(outgoingMessage); } @@ -302,7 +304,8 @@ public final class MultiShareSender { StoryType.NONE, buildLinkPreviews(context, multiShareArgs.getLinkPreview()), validatedMentions, - false); + false, + multiShareArgs.getBodyRanges()); outgoingMessages.add(outgoingMessage); } @@ -392,7 +395,7 @@ public final class MultiShareSender { OutgoingMessage outgoingMessage; if (shouldSendAsPush(recipient, forceSms)) { - outgoingMessage = OutgoingMessage.text(recipient, body, expiresIn, System.currentTimeMillis()); + outgoingMessage = OutgoingMessage.text(recipient, body, expiresIn, System.currentTimeMillis(), multiShareArgs.getBodyRanges()); } else { outgoingMessage = OutgoingMessage.sms(recipient, body, subscriptionId); } @@ -419,7 +422,8 @@ public final class MultiShareSender { .toByteArray()), sentTimestamp, storyType.toTextStoryType(), - buildLinkPreviews(context, multiShareArgs.getLinkPreview())); + buildLinkPreviews(context, multiShareArgs.getLinkPreview()), + multiShareArgs.getBodyRanges()); } private static @NonNull String getBodyForTextStory(@Nullable String draftText, @Nullable LinkPreview linkPreview) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt index cfe6a575c..8696a57c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostModel.kt @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.fonts.TextFont @@ -42,13 +43,15 @@ import java.security.MessageDigest data class StoryTextPostModel( private val storyTextPost: StoryTextPost, private val storySentAtMillis: Long, - private val storyAuthor: RecipientId + private val storyAuthor: RecipientId, + private val bodyRanges: BodyRangeList? ) : Key, Parcelable { override fun updateDiskCacheKey(messageDigest: MessageDigest) { messageDigest.update(storyTextPost.toByteArray()) messageDigest.update(storySentAtMillis.toString().toByteArray()) messageDigest.update(storyAuthor.serialize().toByteArray()) + messageDigest.update(bodyRanges?.toByteArray() ?: ByteArray(0)) } val text: String = storyTextPost.body @@ -65,6 +68,7 @@ data class StoryTextPostModel( ParcelUtil.writeByteArray(parcel, storyTextPost.toByteArray()) parcel.writeLong(storySentAtMillis) parcel.writeParcelable(storyAuthor, flags) + ParcelUtil.writeByteArray(parcel, bodyRanges?.toByteArray()) } override fun describeContents(): Int { @@ -73,12 +77,11 @@ data class StoryTextPostModel( companion object CREATOR : Parcelable.Creator { override fun createFromParcel(parcel: Parcel): StoryTextPostModel { - val storyTextPostArray = ParcelUtil.readByteArray(parcel) - return StoryTextPostModel( - StoryTextPost.parseFrom(storyTextPostArray), - parcel.readLong(), - parcel.readParcelable(RecipientId::class.java.classLoader)!! + storyTextPost = StoryTextPost.parseFrom(ParcelUtil.readByteArray(parcel)), + storySentAtMillis = parcel.readLong(), + storyAuthor = parcel.readParcelable(RecipientId::class.java.classLoader)!!, + bodyRanges = ParcelUtil.readByteArray(parcel)?.let { BodyRangeList.parseFrom(it) } ) } @@ -88,19 +91,21 @@ data class StoryTextPostModel( fun parseFrom(messageRecord: MessageRecord): StoryTextPostModel { return parseFrom( - messageRecord.body, - messageRecord.timestamp, - if (messageRecord.isOutgoing) Recipient.self().id else messageRecord.individualRecipient.id + body = messageRecord.body, + storySentAtMillis = messageRecord.timestamp, + storyAuthor = if (messageRecord.isOutgoing) Recipient.self().id else messageRecord.individualRecipient.id, + bodyRanges = messageRecord.messageRanges ) } @JvmStatic @Throws(IOException::class) - fun parseFrom(body: String, storySentAtMillis: Long, storyAuthor: RecipientId): StoryTextPostModel { + fun parseFrom(body: String, storySentAtMillis: Long, storyAuthor: RecipientId, bodyRanges: BodyRangeList?): StoryTextPostModel { return StoryTextPostModel( storyTextPost = StoryTextPost.parseFrom(Base64.decode(body)), storySentAtMillis = storySentAtMillis, - storyAuthor = storyAuthor + storyAuthor = storyAuthor, + bodyRanges = bodyRanges ) } } @@ -135,7 +140,7 @@ data class StoryTextPostModel( val useLargeThumbnail = source.text.isBlank() view.setTypeface(typeface) - view.bindFromStoryTextPost(source.storyTextPost) + view.bindFromStoryTextPost(source.storyTextPost, source.bodyRanges) view.bindLinkPreview(linkPreview, useLargeThumbnail, loadThumbnail = false) view.postAdjustLinkPreviewTranslationY() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt index d7805cbd8..e6180a71a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryTextPostView.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.stories import android.content.Context import android.graphics.Typeface import android.graphics.drawable.Drawable +import android.text.SpannableString import android.util.AttributeSet import android.view.View import android.widget.ImageView @@ -13,7 +14,9 @@ import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ClippedCardView +import org.thoughtcrime.securesms.conversation.MessageStyler import org.thoughtcrime.securesms.conversation.colors.ChatColors +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost import org.thoughtcrime.securesms.fonts.TextFont import org.thoughtcrime.securesms.linkpreview.LinkPreview @@ -119,17 +122,21 @@ class StoryTextPostView @JvmOverloads constructor( postAdjustLinkPreviewTranslationY() } - fun bindFromStoryTextPost(storyTextPost: StoryTextPost) { + fun bindFromStoryTextPost(storyTextPost: StoryTextPost, bodyRanges: BodyRangeList?) { visible = true linkPreviewView.visible = false - val font = TextFont.fromStyle(storyTextPost.style) + val font: TextFont = TextFont.fromStyle(storyTextPost.style) setPostBackground(ChatColors.forChatColor(ChatColors.Id.NotSet, storyTextPost.background).chatBubbleMask) if (font.isAllCaps) { setText(storyTextPost.body.uppercase(Locale.getDefault()), false) } else { - setText(storyTextPost.body, false) + val body = SpannableString(storyTextPost.body) + if (font == TextFont.REGULAR && bodyRanges != null) { + MessageStyler.style(bodyRanges, body) + } + setText(body, false) } setTextColor(storyTextPost.textForegroundColor, false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/AddToGroupStoryDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/AddToGroupStoryDelegate.kt index 0a81ca6ff..573bdcf90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/AddToGroupStoryDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/AddToGroupStoryDelegate.kt @@ -132,6 +132,7 @@ class AddToGroupStoryDelegate( .withMedia(result.nonUploadedMedia.toList()) .withDraftText(result.body) .withMentions(result.mentions.toList()) + .withBodyRanges(result.bodyRanges) .build() val results = MultiShareSender.sendSync(multiShareArgs) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index d00ed05ac..f29b5c403 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -9,6 +9,7 @@ import android.content.res.ColorStateList import android.graphics.drawable.Drawable import android.media.AudioManager import android.os.Bundle +import android.text.SpannableString import android.text.method.ScrollingMovementMethod import android.view.GestureDetector import android.view.MotionEvent @@ -46,12 +47,14 @@ import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto20dp import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.conversation.MessageStyler import org.thoughtcrime.securesms.conversation.colors.AvatarColor import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardBottomSheet import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment import org.thoughtcrime.securesms.mediapreview.VideoControlsDelegate import org.thoughtcrime.securesms.mms.GlideApp @@ -803,8 +806,14 @@ class StoryViewerPageFragment : @SuppressLint("SetTextI18n") private fun presentCaption(caption: TextView, largeCaption: TextView, largeCaptionOverlay: View, storyPost: StoryPost) { - val displayBody: String = if (storyPost.content is StoryPost.Content.AttachmentContent) { - storyPost.content.attachment.caption ?: "" + val displayBody: CharSequence = if (storyPost.content is StoryPost.Content.AttachmentContent) { + val displayBodySpan = SpannableString(storyPost.content.attachment.caption ?: "") + val ranges: BodyRangeList? = storyPost.conversationMessage.messageRecord.messageRanges + if (ranges != null && displayBodySpan.isNotEmpty()) { + MessageStyler.style(ranges, displayBodySpan) + } + + displayBodySpan } else { "" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostState.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostState.kt index 5bc394bf2..2f93ecb7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostState.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.stories.viewer.post import android.graphics.Typeface import android.net.Uri import org.thoughtcrime.securesms.blurhash.BlurHash +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost import org.thoughtcrime.securesms.linkpreview.LinkPreview import kotlin.time.Duration @@ -12,6 +13,7 @@ sealed class StoryPostState { val storyTextPost: StoryTextPost? = null, val linkPreview: LinkPreview? = null, val typeface: Typeface? = null, + val bodyRanges: BodyRangeList? = null, val loadState: LoadState = LoadState.INIT ) : StoryPostState() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostViewModel.kt index 3330bc1ad..8770a3e38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryPostViewModel.kt @@ -78,22 +78,22 @@ class StoryPostViewModel(private val repository: StoryTextPostRepository) : View .doOnError { Log.w(TAG, "Failed to get typeface. Rendering with default.", it) } .onErrorReturn { Typeface.DEFAULT } - val postAndPreviews = repository.getRecord(recordId) - .map { - if (it.body.isNotEmpty()) { - StoryTextPost.parseFrom(Base64.decode(it.body)) to it.linkPreviews.firstOrNull() + disposables += Single.zip(typeface, repository.getRecord(recordId), ::Pair).subscribeBy( + onSuccess = { (t, record) -> + val text: StoryTextPost = if (record.body.isNotEmpty()) { + StoryTextPost.parseFrom(Base64.decode(record.body)) } else { throw Exception("Text post message body is empty.") } - } - disposables += Single.zip(typeface, postAndPreviews, ::Pair).subscribeBy( - onSuccess = { (t, p) -> + val linkPreview = record.linkPreviews.firstOrNull() + store.update { StoryPostState.TextPost( - storyTextPost = p.first, - linkPreview = p.second, + storyTextPost = text, + linkPreview = linkPreview, typeface = t, + bodyRanges = record.messageRanges, loadState = StoryPostState.LoadState.LOADED ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextLoader.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextLoader.kt index a2245a2c9..736d670f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextLoader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/post/StoryTextLoader.kt @@ -21,7 +21,7 @@ class StoryTextLoader( ) { fun load() { - text.bindFromStoryTextPost(state.storyTextPost!!) + text.bindFromStoryTextPost(state.storyTextPost!!, state.bodyRanges) text.bindLinkPreview(state.linkPreview, state.storyTextPost.body.isBlank()) text.postAdjustLinkPreviewTranslationY() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt index 9ac312765..2a2f84cd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/composer/StoryReplyComposer.kt @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiToggle import org.thoughtcrime.securesms.components.emoji.MediaKeyboard import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.mms.GlideApp import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.recipients.Recipient @@ -124,13 +125,14 @@ class StoryReplyComposer @JvmOverloads constructor( privacyChrome.visible = true } - fun consumeInput(): Pair> { + fun consumeInput(): Input { val trimmedText = input.textTrimmed.toString() val mentions = input.mentions + val bodyRanges = input.styling input.setText("") - return trimmedText to mentions + return Input(trimmedText, mentions, bodyRanges) } fun openEmojiSearch() { @@ -173,4 +175,6 @@ class StoryReplyComposer @JvmOverloads constructor( fun onShowEmojiKeyboard() = Unit fun onHideEmojiKeyboard() = Unit } + + data class Input(val body: String, val mentions: List, val bodyRanges: BodyRangeList?) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt index 4974eef1c..07a20d5dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyDialogFragment.kt @@ -73,7 +73,8 @@ class StoryDirectReplyDialogFragment : composer = view.findViewById(R.id.input) composer.callback = object : StoryReplyComposer.Callback { override fun onSendActionClicked() { - lifecycleDisposable += viewModel.sendReply(composer.consumeInput().first) + val (body, _, bodyRanges) = composer.consumeInput() + lifecycleDisposable += viewModel.sendReply(body, bodyRanges) .observeOn(AndroidSchedulers.mainThread()) .subscribe { Toast.makeText(requireContext(), R.string.StoryDirectReplyDialogFragment__sending_reply, Toast.LENGTH_LONG).show() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt index 419127944..c940706dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyRepository.kt @@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ParentStoryId -import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.mms.OutgoingMessage import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.recipients.Recipient @@ -26,7 +26,7 @@ class StoryDirectReplyRepository(context: Context) { }.subscribeOn(Schedulers.io()) } - fun send(storyId: Long, groupDirectReplyRecipientId: RecipientId?, charSequence: CharSequence, isReaction: Boolean): Completable { + fun send(storyId: Long, groupDirectReplyRecipientId: RecipientId?, body: CharSequence, bodyRangeList: BodyRangeList?, isReaction: Boolean): Completable { return Completable.create { emitter -> val message = SignalDatabase.messages.getMessageRecord(storyId) as MediaMmsMessageRecord val (recipient, threadId) = if (groupDirectReplyRecipientId == null) { @@ -44,24 +44,14 @@ class StoryDirectReplyRepository(context: Context) { MessageSender.send( context, OutgoingMessage( - recipient, - charSequence.toString(), - emptyList(), - System.currentTimeMillis(), - 0, - TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()), - false, - 0, - StoryType.NONE, - ParentStoryId.DirectReply(storyId), - isReaction, - QuoteModel(message.dateSent, quoteAuthor.id, message.body, false, message.slideDeck.asAttachments(), null, QuoteModel.Type.NORMAL), - emptyList(), - emptyList(), - emptyList(), - emptySet(), - emptySet(), - null + recipient = recipient, + body = body.toString(), + sentTimeMillis = System.currentTimeMillis(), + expiresIn = TimeUnit.SECONDS.toMillis(recipient.expiresInSeconds.toLong()), + parentStoryId = ParentStoryId.DirectReply(storyId), + isStoryReaction = isReaction, + outgoingQuote = QuoteModel(message.dateSent, quoteAuthor.id, message.body, false, message.slideDeck.asAttachments(), null, QuoteModel.Type.NORMAL, message.messageRanges), + bodyRanges = bodyRangeList ), threadId, MessageSender.SendType.SIGNAL, diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt index ffe4424b2..29983c17d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/direct/StoryDirectReplyViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.util.livedata.Store @@ -33,12 +34,24 @@ class StoryDirectReplyViewModel( } } - fun sendReply(charSequence: CharSequence): Completable { - return repository.send(storyId, groupDirectReplyRecipientId, charSequence, false) + fun sendReply(body: CharSequence, bodyRangeList: BodyRangeList?): Completable { + return repository.send( + storyId = storyId, + groupDirectReplyRecipientId = groupDirectReplyRecipientId, + body = body, + bodyRangeList = bodyRangeList, + isReaction = false + ) } fun sendReaction(emoji: CharSequence): Completable { - return repository.send(storyId, groupDirectReplyRecipientId, emoji, true) + return repository.send( + storyId = storyId, + groupDirectReplyRecipientId = groupDirectReplyRecipientId, + body = emoji, + bodyRangeList = null, + isReaction = true + ) } override fun onCleared() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt index 39a411037..d15ee8305 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt @@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewMod import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.jobs.RetrieveProfileJob import org.thoughtcrime.securesms.keyboard.KeyboardPage @@ -154,6 +155,7 @@ class StoryGroupReplyFragment : private var resendBody: CharSequence? = null private var resendMentions: List = emptyList() private var resendReaction: String? = null + private var resendBodyRanges: BodyRangeList? = null private lateinit var inlineQueryResultsController: InlineQueryResultsController @@ -349,8 +351,8 @@ class StoryGroupReplyFragment : } override fun onSendActionClicked() { - val (body, mentions) = composer.consumeInput() - performSend(body, mentions) + val (body, mentions, bodyRanges) = composer.consumeInput() + performSend(body, mentions, bodyRanges) } override fun onPickReactionClicked() { @@ -530,14 +532,15 @@ class StoryGroupReplyFragment : } } - private fun performSend(body: CharSequence, mentions: List) { - lifecycleDisposable += StoryGroupReplySender.sendReply(requireContext(), storyId, body, mentions) + private fun performSend(body: CharSequence, mentions: List, bodyRanges: BodyRangeList?) { + lifecycleDisposable += StoryGroupReplySender.sendReply(requireContext(), storyId, body, mentions, bodyRanges) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy( onError = { throwable -> if (throwable is UntrustedRecords.UntrustedRecordsException) { resendBody = body resendMentions = mentions + resendBodyRanges = bodyRanges SafetyNumberBottomSheet .forIdentityRecordsAndDestination(throwable.untrustedRecords, ContactSearchKey.RecipientSearchKey(groupRecipientId, true)) @@ -557,7 +560,7 @@ class StoryGroupReplyFragment : val resendBody = resendBody val resendReaction = resendReaction if (resendBody != null) { - performSend(resendBody, resendMentions) + performSend(resendBody, resendMentions, resendBodyRanges) } else if (resendReaction != null) { sendReaction(resendReaction) } @@ -571,6 +574,7 @@ class StoryGroupReplyFragment : resendBody = null resendMentions = emptyList() resendReaction = null + resendBodyRanges = null } @ColorInt diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt index e26b0e559..bba4c9fdf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplySender.kt @@ -9,7 +9,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.identity.IdentityRecordList import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.ParentStoryId -import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.mediasend.v2.UntrustedRecords import org.thoughtcrime.securesms.mms.OutgoingMessage import org.thoughtcrime.securesms.sms.MessageSender @@ -19,15 +19,29 @@ import org.thoughtcrime.securesms.sms.MessageSender */ object StoryGroupReplySender { - fun sendReply(context: Context, storyId: Long, body: CharSequence, mentions: List): Completable { - return sendInternal(context, storyId, body, mentions, false) + fun sendReply(context: Context, storyId: Long, body: CharSequence, mentions: List, bodyRanges: BodyRangeList?): Completable { + return sendInternal( + context = context, + storyId = storyId, + body = body, + mentions = mentions, + bodyRanges = bodyRanges, + isReaction = false + ) } fun sendReaction(context: Context, storyId: Long, emoji: String): Completable { - return sendInternal(context, storyId, emoji, emptyList(), true) + return sendInternal( + context = context, + storyId = storyId, + body = emoji, + mentions = emptyList(), + bodyRanges = null, + isReaction = true + ) } - private fun sendInternal(context: Context, storyId: Long, body: CharSequence, mentions: List, isReaction: Boolean): Completable { + private fun sendInternal(context: Context, storyId: Long, body: CharSequence, mentions: List, bodyRanges: BodyRangeList?, isReaction: Boolean): Completable { val messageAndRecipient = Single.fromCallable { val message = SignalDatabase.messages.getMessageRecord(storyId) val recipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)!! @@ -44,13 +58,12 @@ object StoryGroupReplySender { OutgoingMessage( recipient = recipient, body = body.toString(), - timestamp = System.currentTimeMillis(), - distributionType = 0, - storyType = StoryType.NONE, + sentTimeMillis = System.currentTimeMillis(), parentStoryId = ParentStoryId.GroupReply(message.id), isStoryReaction = isReaction, mentions = mentions, - isSecure = true + isSecure = true, + bodyRanges = bodyRanges ), message.threadId, MessageSender.SendType.SIGNAL, 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 dba0fbf39..f21e0481a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -104,6 +104,7 @@ 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.2"; + private static final String TEXT_FORMATTING = "android.textFormatting"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -158,7 +159,8 @@ public final class FeatureFlags { CDS_HARD_LIMIT, CHAT_FILTERS, PAYPAL_ONE_TIME_DONATIONS, - PAYPAL_RECURRING_DONATIONS + PAYPAL_RECURRING_DONATIONS, + TEXT_FORMATTING ); @VisibleForTesting @@ -220,7 +222,8 @@ public final class FeatureFlags { RECIPIENT_MERGE_V2, CREDIT_CARD_PAYMENTS, PAYMENTS_REQUEST_ACTIVATE_FLOW, - CDS_HARD_LIMIT + CDS_HARD_LIMIT, + TEXT_FORMATTING ); /** @@ -564,6 +567,13 @@ 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); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java index 74a754e31..33027b33b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SearchUtil.java @@ -10,6 +10,7 @@ import androidx.annotation.Nullable; import com.annimon.stream.Stream; +import org.signal.core.util.StringUtil; import org.signal.libsignal.protocol.util.Pair; import java.security.InvalidParameterException; @@ -25,7 +26,7 @@ public class SearchUtil { public static Spannable getHighlightedSpan(@NonNull Locale locale, @NonNull StyleFactory styleFactory, - @Nullable String text, + @Nullable CharSequence text, @Nullable String highlight, int matchMode) { @@ -33,9 +34,9 @@ public class SearchUtil { return new SpannableString(""); } - text = text.replaceAll("\n", " "); + text = StringUtil.replace(text, '\n', " "); - return getHighlightedSpan(locale, styleFactory, new SpannableString(text), highlight, matchMode); + return getHighlightedSpan(locale, styleFactory, SpannableString.valueOf(text), highlight, matchMode); } public static Spannable getHighlightedSpan(@NonNull Locale locale, @@ -53,7 +54,7 @@ public class SearchUtil { return text; } - SpannableString spanned = new SpannableString(text); + SpannableString spanned = SpannableString.valueOf(text); List> ranges; switch (matchMode) { @@ -67,8 +68,11 @@ public class SearchUtil { throw new InvalidParameterException("match mode must be STRICT or MATCH_ALL: " + matchMode); } + CharacterStyle[] styles = styleFactory.createStyles(); for (Pair range : ranges) { - spanned.setSpan(styleFactory.create(), range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + for (CharacterStyle style : styles) { + spanned.setSpan(style, range.first(), range.second(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE); + } } return spanned; @@ -148,6 +152,6 @@ public class SearchUtil { } public interface StyleFactory { - CharacterStyle create(); + CharacterStyle[] createStyles(); } } diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index 569c67587..581fd564e 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -77,8 +77,11 @@ message ProfileChangeDetails { message BodyRangeList { message BodyRange { enum Style { - BOLD = 0; - ITALIC = 1; + BOLD = 0; + ITALIC = 1; + SPOILER = 2; + STRIKETHROUGH = 3; + MONOSPACE = 4; } message Button { diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 2b7749cdf..0e9fe7ea5 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -22,4 +22,9 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9603a298a..9724256be 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5510,4 +5510,13 @@ Export SMS + + + Bold + + Italic + + Strikethrough + + Monospace diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/BodyRangeUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/BodyRangeUtilTest.kt new file mode 100644 index 000000000..5158c106d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/BodyRangeUtilTest.kt @@ -0,0 +1,118 @@ +package org.thoughtcrime.securesms.database + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList + +class BodyRangeUtilTest { + + @Test + fun testMentionBeforeBodyRange() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(5).setLength(5).build()).build() + val adjustments = listOf(BodyAdjustment(0, 3, 1)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(3, updatedBodyRanges.getRanges(0).start) + assertEquals(5, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun textMentionAfterBodyRange() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(5).setLength(5).build()).build() + val adjustments = listOf(BodyAdjustment(10, 3, 1)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(5, updatedBodyRanges.getRanges(0).start) + assertEquals(5, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun testMentionWithinBodyRange() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(0).setLength(20).build()).build() + val adjustments = listOf(BodyAdjustment(5, 10, 1)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(0, updatedBodyRanges.getRanges(0).start) + assertEquals(11, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun testMentionWithinAndEndOfBodyRange() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(0).setLength(5).build()).build() + val adjustments = listOf(BodyAdjustment(1, 4, 1)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(0, updatedBodyRanges.getRanges(0).start) + assertEquals(2, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun testDoubleMention() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(5).setLength(10).build()).build() + val adjustments = listOf(BodyAdjustment(0, 3, 1), BodyAdjustment(17, 10, 1)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(3, updatedBodyRanges.getRanges(0).start) + assertEquals(10, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun testResolvedMentionBeforeBodyRange() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(10).setLength(20).build()).build() + val adjustments = listOf(BodyAdjustment(0, 1, 10)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(19, updatedBodyRanges.getRanges(0).start) + assertEquals(20, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun textResolvedMentionAfterBodyRange() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(5).setLength(5).build()).build() + val adjustments = listOf(BodyAdjustment(10, 1, 10)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(5, updatedBodyRanges.getRanges(0).start) + assertEquals(5, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun testResolvedMentionWithinBodyRange() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(0).setLength(20).build()).build() + val adjustments = listOf(BodyAdjustment(5, 1, 11)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(0, updatedBodyRanges.getRanges(0).start) + assertEquals(30, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun testResolvedMentionWithinAndEndOfBodyRange() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(0).setLength(2).build()).build() + val adjustments = listOf(BodyAdjustment(1, 1, 4)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(0, updatedBodyRanges.getRanges(0).start) + assertEquals(5, updatedBodyRanges.getRanges(0).length) + } + + @Test + fun testDoubleResolvedMention() { + val bodyRangeList = BodyRangeList.newBuilder().addRanges(BodyRangeList.BodyRange.newBuilder().setStart(2).setLength(4).build()).build() + val adjustments = listOf(BodyAdjustment(0, 1, 8), BodyAdjustment(7, 1, 11)) + + val updatedBodyRanges = bodyRangeList.adjustBodyRanges(adjustments)!! + + assertEquals(9, updatedBodyRanges.getRanges(0).start) + assertEquals(4, updatedBodyRanges.getRanges(0).length) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/MentionUtilTest.kt b/app/src/test/java/org/thoughtcrime/securesms/database/MentionUtilTest.kt index 931a6be57..2545a1dd5 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/MentionUtilTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/MentionUtilTest.kt @@ -20,7 +20,7 @@ class MentionUtilTest { val update: MentionUtil.UpdatedBodyAndMentions = MentionUtil.update("T test", mentions) { it.recipientId.toString() } - assertThat(update.body, Matchers.`is`("RecipientId::1 test")) + assertThat(update.body.toString(), Matchers.`is`("RecipientId::1 test")) } @Test @@ -32,7 +32,7 @@ class MentionUtilTest { val update: MentionUtil.UpdatedBodyAndMentions = MentionUtil.update("ONETWO test", mentions) { it.recipientId.toString() } - assertThat(update.body, Matchers.`is`("RecipientId::1RecipientId::2 test")) + assertThat(update.body.toString(), Matchers.`is`("RecipientId::1RecipientId::2 test")) } @Test @@ -44,6 +44,6 @@ class MentionUtilTest { val update: MentionUtil.UpdatedBodyAndMentions = MentionUtil.update("T test", mentions) { it.recipientId.toString() } - assertThat(update.body, Matchers.`is`("RecipientId::1est")) + assertThat(update.body.toString(), Matchers.`is`("RecipientId::1est")) } } diff --git a/core-util/src/main/java/org/signal/core/util/StringUtil.java b/core-util/src/main/java/org/signal/core/util/StringUtil.java index 66357ceb2..15923430f 100644 --- a/core-util/src/main/java/org/signal/core/util/StringUtil.java +++ b/core-util/src/main/java/org/signal/core/util/StringUtil.java @@ -1,5 +1,7 @@ package org.signal.core.util; +import android.text.Spannable; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import androidx.annotation.NonNull; @@ -326,4 +328,52 @@ public final class StringUtil { public static String forceLtr(@NonNull CharSequence text) { return "\u202a" + text + "\u202c"; } + + public static @NonNull CharSequence replace(@NonNull CharSequence text, char toReplace, String replacement) { + SpannableStringBuilder updatedText = null; + + for (int i = text.length() - 1; i >= 0; i--) { + if (text.charAt(i) == toReplace) { + if (updatedText == null) { + updatedText = SpannableStringBuilder.valueOf(text); + } + updatedText.replace(i, i + 1, replacement); + } + } + + if (updatedText != null) { + return updatedText; + } else { + return text; + } + } + + public static boolean startsWith(@NonNull CharSequence text, @NonNull CharSequence substring) { + if (substring.length() > text.length()) { + return false; + } + + for (int i = 0; i < substring.length(); i++) { + if (text.charAt(i) != substring.charAt(i)) { + return false; + } + } + + return true; + } + + public static boolean endsWith(@NonNull CharSequence text, @NonNull CharSequence substring) { + if (substring.length() > text.length()) { + return false; + } + + int textIndex = text.length() - 1; + for (int substringIndex = substring.length() - 1; substringIndex >= 0; substringIndex--, textIndex--) { + if (text.charAt(textIndex) != substring.charAt(substringIndex)) { + return false; + } + } + + return true; + } } diff --git a/core-util/src/test/java/org/signal/core/util/StringUtilTest_endsWith.kt b/core-util/src/test/java/org/signal/core/util/StringUtilTest_endsWith.kt new file mode 100644 index 000000000..324f0c134 --- /dev/null +++ b/core-util/src/test/java/org/signal/core/util/StringUtilTest_endsWith.kt @@ -0,0 +1,54 @@ +@file:Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + +package org.signal.core.util + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters +import org.robolectric.annotation.Config +import java.lang.Boolean as JavaBoolean + +@Suppress("ClassName") +@RunWith(value = ParameterizedRobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class StringUtilTest_endsWith { + + @Parameter(0) + lateinit var text: CharSequence + + @Parameter(1) + lateinit var substring: CharSequence + + @Parameter(2) + lateinit var expected: JavaBoolean + + companion object { + @JvmStatic + @Parameters + fun data(): Collection> { + return listOf( + arrayOf("Text", "xt", true), + arrayOf("Text", "", true), + arrayOf("Text", "XT", false), + arrayOf("Text…", "xt…", true), + arrayOf("", "Te", false), + arrayOf("Text", "Text", true), + arrayOf("Text", "2Text", false), + arrayOf("\uD83D\uDC64Text", "Te", false), + arrayOf("Text text text\uD83D\uDC64", "\uD83D\uDC64", true), + arrayOf("Text\uD83D\uDC64Text", "\uD83D\uDC64Text", true) + ) + } + } + + @Test + fun replace() { + val result = StringUtil.endsWith(text, substring) + + assertEquals(expected, result) + } +} \ No newline at end of file diff --git a/core-util/src/test/java/org/signal/core/util/StringUtilTest_replace.kt b/core-util/src/test/java/org/signal/core/util/StringUtilTest_replace.kt new file mode 100644 index 000000000..6f99c2d99 --- /dev/null +++ b/core-util/src/test/java/org/signal/core/util/StringUtilTest_replace.kt @@ -0,0 +1,51 @@ +package org.signal.core.util + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters +import org.robolectric.annotation.Config + +@Suppress("ClassName") +@RunWith(value = ParameterizedRobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class StringUtilTest_replace { + + @Parameter(0) + lateinit var text: CharSequence + + @Parameter(1) + lateinit var charToReplace: Character + + @Parameter(2) + lateinit var replacement: String + + @Parameter(3) + lateinit var expected: CharSequence + + companion object { + @JvmStatic + @Parameters + fun data(): Collection> { + return listOf( + arrayOf("Replace\nme", '\n', " ", "Replace me"), + arrayOf("Replace me", '\n', " ", "Replace me"), + arrayOf("\nReplace me", '\n', " ", " Replace me"), + arrayOf("Replace me\n", '\n', " ", "Replace me "), + arrayOf("Replace\n\nme", '\n', " ", "Replace me"), + arrayOf("Replace\nme\n", '\n', " ", "Replace me "), + arrayOf("\n\nReplace\n\nme\n", '\n', " ", " Replace me ") + ) + } + } + + @Test + fun replace() { + val result = StringUtil.replace(text, charToReplace.charValue(), replacement) + + assertEquals(expected.toString(), result.toString()) + } +} \ No newline at end of file diff --git a/core-util/src/test/java/org/signal/core/util/StringUtilTest_startsWith.kt b/core-util/src/test/java/org/signal/core/util/StringUtilTest_startsWith.kt new file mode 100644 index 000000000..3e5ca2a9f --- /dev/null +++ b/core-util/src/test/java/org/signal/core/util/StringUtilTest_startsWith.kt @@ -0,0 +1,54 @@ +@file:Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + +package org.signal.core.util + +import android.app.Application +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.ParameterizedRobolectricTestRunner.Parameter +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters +import org.robolectric.annotation.Config +import java.lang.Boolean as JavaBoolean + +@Suppress("ClassName") +@RunWith(value = ParameterizedRobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class StringUtilTest_startsWith { + + @Parameter(0) + lateinit var text: CharSequence + + @Parameter(1) + lateinit var substring: CharSequence + + @Parameter(2) + lateinit var expected: JavaBoolean + + companion object { + @JvmStatic + @Parameters + fun data(): Collection> { + return listOf( + arrayOf("Text", "Te", true), + arrayOf("Text", "", true), + arrayOf("Text", "te", false), + arrayOf("…Text", "…Te", true), + arrayOf("", "Te", false), + arrayOf("Text", "Text", true), + arrayOf("Text", "Text2", false), + arrayOf("\uD83D\uDC64Text", "Te", false), + arrayOf("Text text text\uD83D\uDC64", "\uD83D\uDC64", false), + arrayOf("\uD83D\uDC64Text", "\uD83D\uDC64Te", true) + ) + } + } + + @Test + fun replace() { + val result = StringUtil.startsWith(text, substring) + + assertEquals(expected, result) + } +} \ No newline at end of file diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index d31404724..71b8af7da 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -101,6 +101,7 @@ import org.whispersystems.signalservice.internal.push.SendGroupMessageResponse; import org.whispersystems.signalservice.internal.push.SendMessageResponse; import org.whispersystems.signalservice.internal.push.SignalServiceProtos; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.BodyRange; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.CallMessage; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content; import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage; @@ -845,6 +846,10 @@ public class SignalServiceMessageSender { builder.setTextAttachment(createTextAttachment(message.getTextAttachment().get())); } + if (message.getBodyRanges().isPresent()) { + builder.addAllBodyRanges(message.getBodyRanges().get()); + } + builder.setAllowsReplies(message.getAllowsReplies().orElse(true)); return container.setStoryMessage(builder).build(); @@ -929,15 +934,20 @@ public class SignalServiceMessageSender { List mentions = message.getQuote().get().getMentions(); if (mentions != null && !mentions.isEmpty()) { for (SignalServiceDataMessage.Mention mention : mentions) { - quoteBuilder.addBodyRanges(DataMessage.BodyRange.newBuilder() - .setStart(mention.getStart()) - .setLength(mention.getLength()) - .setMentionUuid(mention.getServiceId().toString())); + quoteBuilder.addBodyRanges(BodyRange.newBuilder() + .setStart(mention.getStart()) + .setLength(mention.getLength()) + .setMentionUuid(mention.getServiceId().toString())); } builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.MENTIONS_VALUE, builder.getRequiredProtocolVersion())); } + List bodyRanges = message.getQuote().get().getBodyRanges(); + if (bodyRanges != null) { + quoteBuilder.addAllBodyRanges(bodyRanges); + } + List attachments = message.getQuote().get().getAttachments(); if (attachments != null) { for (SignalServiceDataMessage.Quote.QuotedAttachment attachment : attachments) { @@ -972,10 +982,10 @@ public class SignalServiceMessageSender { if (message.getMentions().isPresent()) { for (SignalServiceDataMessage.Mention mention : message.getMentions().get()) { - builder.addBodyRanges(DataMessage.BodyRange.newBuilder() - .setStart(mention.getStart()) - .setLength(mention.getLength()) - .setMentionUuid(mention.getServiceId().toString())); + builder.addBodyRanges(BodyRange.newBuilder() + .setStart(mention.getStart()) + .setLength(mention.getLength()) + .setMentionUuid(mention.getServiceId().toString())); } builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.MENTIONS_VALUE, builder.getRequiredProtocolVersion())); } @@ -1060,6 +1070,10 @@ public class SignalServiceMessageSender { .setReceiptCredentialPresentation(ByteString.copyFrom(giftBadge.getReceiptCredentialPresentation().serialize()))); } + if (message.getBodyRanges().isPresent()) { + builder.addAllBodyRanges(message.getBodyRanges().get()); + } + builder.setTimestamp(message.getTimestamp()); return enforceMaxContentSize(container.setDataMessage(builder).build()); diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index f93dd4994..65f319c96 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -680,9 +680,9 @@ import javax.annotation.Nullable; Optional groupContext = Optional.ofNullable(groupInfoV2); List attachments = new LinkedList<>(); - boolean endSession = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.END_SESSION_VALUE ) != 0); + boolean endSession = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.END_SESSION_VALUE) != 0); boolean expirationUpdate = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.EXPIRATION_TIMER_UPDATE_VALUE) != 0); - boolean profileKeyUpdate = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.PROFILE_KEY_UPDATE_VALUE ) != 0); + boolean profileKeyUpdate = ((content.getFlags() & SignalServiceProtos.DataMessage.Flags.PROFILE_KEY_UPDATE_VALUE) != 0); boolean isGroupV2 = groupInfoV2 != null; SignalServiceDataMessage.Quote quote = createQuote(content, isGroupV2); List sharedContacts = createSharedContacts(content); @@ -694,6 +694,7 @@ import javax.annotation.Nullable; SignalServiceDataMessage.GroupCallUpdate groupCallUpdate = createGroupCallUpdate(content); SignalServiceDataMessage.StoryContext storyContext = createStoryContext(content); SignalServiceDataMessage.GiftBadge giftBadge = createGiftBadge(content); + List bodyRanges = createBodyRanges(content.getBodyRangesList(), content.getBody()); if (content.getRequiredProtocolVersion() > SignalServiceProtos.DataMessage.ProtocolVersion.CURRENT_VALUE) { throw new UnsupportedDataMessageProtocolVersionException(SignalServiceProtos.DataMessage.ProtocolVersion.CURRENT_VALUE, @@ -745,6 +746,7 @@ import javax.annotation.Nullable; .withPayment(payment) .withStoryContext(storyContext) .withGiftBadge(giftBadge) + .withBodyRanges(bodyRanges) .build(); } @@ -1092,12 +1094,14 @@ import javax.annotation.Nullable; return SignalServiceStoryMessage.forFileAttachment(profileKey, createGroupV2Info(content), createAttachmentPointer(content.getFileAttachment()), - content.getAllowsReplies()); + content.getAllowsReplies(), + content.getBodyRangesList()); } else { return SignalServiceStoryMessage.forTextAttachment(profileKey, createGroupV2Info(content), createTextAttachment(content.getTextAttachment()), - content.getAllowsReplies()); + content.getAllowsReplies(), + content.getBodyRangesList()); } } @@ -1121,7 +1125,8 @@ import javax.annotation.Nullable; content.getQuote().getText(), attachments, createMentions(content.getQuote().getBodyRangesList(), content.getQuote().getText(), isGroupV2), - SignalServiceDataMessage.Quote.Type.fromProto(content.getQuote().getType())); + SignalServiceDataMessage.Quote.Type.fromProto(content.getQuote().getType()), + createBodyRanges(content.getQuote().getBodyRangesList(), content.getQuote().getText())); } else { Log.w(TAG, "Quote was missing an author! Returning null."); return null; @@ -1154,7 +1159,7 @@ import javax.annotation.Nullable; Optional.ofNullable(attachment)); } - private static @Nullable List createMentions(List bodyRanges, String body, boolean isGroupV2) + private static @Nullable List createMentions(List bodyRanges, String body, boolean isGroupV2) throws InvalidMessageStructureException { if (bodyRanges == null || bodyRanges.isEmpty() || body == null) { @@ -1163,7 +1168,7 @@ import javax.annotation.Nullable; List mentions = new LinkedList<>(); - for (SignalServiceProtos.DataMessage.BodyRange bodyRange : bodyRanges) { + for (SignalServiceProtos.BodyRange bodyRange : bodyRanges) { if (bodyRange.hasMentionUuid()) { try { mentions.add(new SignalServiceDataMessage.Mention(ServiceId.parseOrThrow(bodyRange.getMentionUuid()), bodyRange.getStart(), bodyRange.getLength())); @@ -1180,6 +1185,22 @@ import javax.annotation.Nullable; return mentions; } + private static @Nullable List createBodyRanges(List bodyRanges, String body) { + if (bodyRanges == null || bodyRanges.isEmpty() || body == null) { + return null; + } + + List ranges = new LinkedList<>(); + + for (SignalServiceProtos.BodyRange bodyRange : bodyRanges) { + if (bodyRange.hasStyle()) { + ranges.add(bodyRange); + } + } + + return ranges; + } + private static @Nullable SignalServiceDataMessage.Sticker createSticker(SignalServiceProtos.DataMessage content) throws InvalidMessageStructureException { if (!content.hasSticker() || !content.getSticker().hasPackId() || diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt index 03d287f39..c63594491 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.kt @@ -11,6 +11,7 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact import org.whispersystems.signalservice.api.push.ServiceId import org.whispersystems.signalservice.api.util.OptionalUtil.asOptional import org.whispersystems.signalservice.api.util.OptionalUtil.emptyIfStringEmpty +import org.whispersystems.signalservice.internal.push.SignalServiceProtos.BodyRange import java.util.LinkedList import java.util.Optional import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage.Payment as PaymentProto @@ -47,7 +48,8 @@ class SignalServiceDataMessage private constructor( val groupCallUpdate: Optional, val payment: Optional, val storyContext: Optional, - val giftBadge: Optional + val giftBadge: Optional, + val bodyRanges: Optional> ) { val isActivatePaymentsRequest: Boolean = payment.map { it.isActivationRequest }.orElse(false) val isPaymentsActivated: Boolean = payment.map { it.isActivation }.orElse(false) @@ -92,6 +94,7 @@ class SignalServiceDataMessage private constructor( private var payment: Payment? = null private var storyContext: StoryContext? = null private var giftBadge: GiftBadge? = null + private var bodyRanges: MutableList = LinkedList() fun withTimestamp(timestamp: Long): Builder { this.timestamp = timestamp @@ -210,6 +213,11 @@ class SignalServiceDataMessage private constructor( return this } + fun withBodyRanges(bodyRanges: List?): Builder { + bodyRanges?.let { this.bodyRanges.addAll(bodyRanges) } + return this + } + fun build(): SignalServiceDataMessage { if (timestamp == 0L) { timestamp = System.currentTimeMillis() @@ -236,7 +244,8 @@ class SignalServiceDataMessage private constructor( groupCallUpdate = groupCallUpdate.asOptional(), payment = payment.asOptional(), storyContext = storyContext.asOptional(), - giftBadge = giftBadge.asOptional() + giftBadge = giftBadge.asOptional(), + bodyRanges = bodyRanges.asOptional() ) } } @@ -247,7 +256,8 @@ class SignalServiceDataMessage private constructor( val text: String, val attachments: List?, val mentions: List?, - val type: Type + val type: Type, + val bodyRanges: List? ) { enum class Type(val protoType: QuoteProto.Type) { NORMAL(QuoteProto.Type.NORMAL), diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStoryMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStoryMessage.java index 7c309fadd..e9b5f5f96 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStoryMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceStoryMessage.java @@ -1,39 +1,50 @@ package org.whispersystems.signalservice.api.messages; +import org.whispersystems.signalservice.internal.push.SignalServiceProtos; + +import java.util.List; import java.util.Optional; public class SignalServiceStoryMessage { - private final Optional profileKey; - private final Optional groupContext; - private final Optional fileAttachment; - private final Optional textAttachment; - private final Optional allowsReplies; + private final Optional profileKey; + private final Optional groupContext; + private final Optional fileAttachment; + private final Optional textAttachment; + private final Optional allowsReplies; + private final Optional> bodyRanges; private SignalServiceStoryMessage(byte[] profileKey, SignalServiceGroupV2 groupContext, SignalServiceAttachment fileAttachment, SignalServiceTextAttachment textAttachment, - boolean allowsReplies) { + boolean allowsReplies, + List bodyRanges) + { this.profileKey = Optional.ofNullable(profileKey); this.groupContext = Optional.ofNullable(groupContext); this.fileAttachment = Optional.ofNullable(fileAttachment); this.textAttachment = Optional.ofNullable(textAttachment); this.allowsReplies = Optional.of(allowsReplies); + this.bodyRanges = Optional.ofNullable(bodyRanges); } public static SignalServiceStoryMessage forFileAttachment(byte[] profileKey, SignalServiceGroupV2 groupContext, SignalServiceAttachment fileAttachment, - boolean allowsReplies) { - return new SignalServiceStoryMessage(profileKey, groupContext, fileAttachment, null, allowsReplies); + boolean allowsReplies, + List bodyRanges) + { + return new SignalServiceStoryMessage(profileKey, groupContext, fileAttachment, null, allowsReplies, bodyRanges); } public static SignalServiceStoryMessage forTextAttachment(byte[] profileKey, SignalServiceGroupV2 groupContext, SignalServiceTextAttachment textAttachment, - boolean allowsReplies) { - return new SignalServiceStoryMessage(profileKey, groupContext, null, textAttachment, allowsReplies); + boolean allowsReplies, + List bodyRanges) + { + return new SignalServiceStoryMessage(profileKey, groupContext, null, textAttachment, allowsReplies, bodyRanges); } public Optional getProfileKey() { @@ -55,4 +66,8 @@ public class SignalServiceStoryMessage { public Optional getAllowsReplies() { return allowsReplies; } + + public Optional> getBodyRanges() { + return bodyRanges; + } } diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index a838a2805..27677636e 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -125,6 +125,25 @@ message CallMessage { optional Opaque opaque = 10; } +message BodyRange { + enum Style { + NONE = 0; + BOLD = 1; + ITALIC = 2; + SPOILER = 3; + STRIKETHROUGH = 4; + MONOSPACE = 5; + } + + optional uint32 start = 1; + optional uint32 length = 2; + + oneof associatedValue { + string mentionUuid = 3; + Style style = 4; + } +} + message DataMessage { enum Flags { END_SESSION = 1; @@ -132,15 +151,6 @@ message DataMessage { PROFILE_KEY_UPDATE = 4; } - message BodyRange { - optional uint32 start = 1; - optional uint32 length = 2; - - oneof associatedValue { - string mentionUuid = 3; - } - } - message Quote { enum Type { NORMAL = 0; @@ -382,6 +392,7 @@ message StoryMessage { TextAttachment textAttachment = 4; } optional bool allowsReplies = 5; + repeated BodyRange bodyRanges = 6; } message Preview {