From b2d4c5d14b283be42b63931a79dda4bdafa6b683 Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Wed, 5 Aug 2020 16:45:52 -0400 Subject: [PATCH] Add mentions for v2 group chats. --- .../securesms/BindableConversationItem.java | 2 +- .../securesms/components/ComposeText.java | 164 +++++++++++------ .../securesms/components/InputPanel.java | 7 +- .../securesms/components/QuoteView.java | 14 +- .../components/emoji/EmojiTextView.java | 52 +++++- .../components/mention/MentionAnnotation.java | 60 ++++++- .../components/mention/MentionDeleter.java | 50 ++++++ .../mention/MentionRendererDelegate.java | 56 +++--- .../mention/MentionValidatorWatcher.java | 58 ++++++ .../conversation/ConversationActivity.java | 152 ++++++++++++---- .../conversation/ConversationDataSource.java | 44 ++++- .../conversation/ConversationFragment.java | 61 +++---- .../conversation/ConversationItem.java | 32 +++- .../conversation/ConversationMessage.java | 107 ++++++++++- .../ui/mentions/MentionViewHolder.java | 8 +- .../ui/mentions/MentionViewState.java | 5 + .../ui/mentions/MentionsPickerFragment.java | 11 +- .../ui/mentions/MentionsPickerRepository.java | 51 ++++++ .../ui/mentions/MentionsPickerViewModel.java | 30 +--- .../securesms/database/DatabaseFactory.java | 6 + .../securesms/database/DraftDatabase.java | 21 +-- .../securesms/database/MentionDatabase.java | 112 ++++++++++++ .../securesms/database/MentionUtil.java | 166 ++++++++++++++++++ .../securesms/database/MmsDatabase.java | 94 ++++++++-- .../securesms/database/MmsSmsDatabase.java | 14 +- .../securesms/database/RecipientDatabase.java | 75 +++++++- .../securesms/database/ThreadBodyUtil.java | 12 +- .../database/helpers/SQLCipherOpenHelper.java | 23 ++- .../database/model/MediaMmsMessageRecord.java | 12 +- .../securesms/database/model/Mention.java | 90 ++++++++++ .../database/model/MessageRecord.java | 4 + .../securesms/database/model/Quote.java | 40 +++-- .../securesms/groups/GroupManagerV1.java | 3 +- .../securesms/groups/GroupManagerV2.java | 1 + .../groups/GroupV1MessageProcessor.java | 2 +- .../ui/managegroup/ManageGroupFragment.java | 10 +- .../ui/managegroup/ManageGroupRepository.java | 8 + .../ui/managegroup/ManageGroupViewModel.java | 14 ++ .../dialogs/GroupMentionSettingDialog.java | 90 ++++++++++ .../v2/processing/GroupsV2StateProcessor.java | 3 +- .../securesms/jobs/PushGroupSendJob.java | 2 + .../securesms/jobs/PushProcessMessageJob.java | 51 +++++- .../securesms/jobs/PushSendJob.java | 10 +- .../securesms/jobs/WakeGroupV2Job.java | 2 +- .../keyvalue/NotificationSettings.java | 20 +++ .../securesms/keyvalue/SignalStore.java | 8 +- .../securesms/longmessage/LongMessage.java | 25 +-- .../longmessage/LongMessageActivity.java | 6 +- .../longmessage/LongMessageRepository.java | 12 +- .../mediasend/MediaSendActivity.java | 64 ++++++- .../mediasend/MediaSendActivityResult.java | 22 ++- .../mediasend/MediaSendViewModel.java | 12 +- .../messagedetails/MessageDetails.java | 13 +- .../MessageDetailsActivity.java | 6 +- .../messagedetails/MessageDetailsAdapter.java | 5 +- .../MessageDetailsRepository.java | 5 +- .../MessageHeaderViewHolder.java | 24 ++- .../securesms/mms/AttachmentManager.java | 2 +- .../securesms/mms/IncomingMediaMessage.java | 8 + .../mms/OutgoingExpirationUpdateMessage.java | 2 +- .../mms/OutgoingGroupUpdateMessage.java | 16 +- .../securesms/mms/OutgoingMediaMessage.java | 14 +- .../mms/OutgoingSecureMediaMessage.java | 6 +- .../securesms/mms/QuoteModel.java | 10 +- .../AndroidAutoReplyReceiver.java | 15 +- .../notifications/DefaultMessageNotifier.java | 26 ++- .../notifications/RemoteReplyReceiver.java | 15 +- .../NotificationsPreferenceFragment.java | 17 ++ .../securesms/recipients/Recipient.java | 8 + .../recipients/RecipientDetails.java | 5 + .../securesms/sharing/ShareActivity.java | 2 +- .../securesms/util/StringUtil.java | 17 ++ .../org/thoughtcrime/securesms/util/Util.java | 4 + app/src/main/proto/Database.proto | 13 ++ .../main/res/layout/group_manage_fragment.xml | 40 +++++ .../layout/group_mention_setting_dialog.xml | 63 +++++++ .../main/res/layout/mediasend_activity.xml | 9 +- .../res/layout/mentions_picker_fragment.xml | 2 +- .../layout/mentions_recipient_list_item.xml | 26 ++- app/src/main/res/layout/quote_view.xml | 1 + app/src/main/res/values/attrs.xml | 2 + app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 11 ++ app/src/main/res/values/themes.xml | 2 + .../preferences_notifications_mentions.xml | 14 ++ ...updateBodyAndMentionsWithPlaceholders.java | 121 +++++++++++++ .../api/SignalServiceMessageSender.java | 25 ++- .../api/messages/SignalServiceContent.java | 34 +++- .../messages/SignalServiceDataMessage.java | 54 +++++- .../src/main/proto/SignalService.proto | 14 +- 90 files changed, 2279 insertions(+), 372 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionDeleter.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionValidatorWatcher.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepository.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/MentionDatabase.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/model/Mention.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/keyvalue/NotificationSettings.java create mode 100644 app/src/main/res/layout/group_mention_setting_dialog.xml create mode 100644 app/src/main/res/xml/preferences_notifications_mentions.xml create mode 100644 app/src/test/java/org/thoughtcrime/securesms/database/MentionUtilTest_updateBodyAndMentionsWithPlaceholders.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index fc6273bb87..9b5e837d06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -47,7 +47,7 @@ public interface BindableConversationItem extends Unbindable { void onMessageSharedContactClicked(@NonNull List choices); void onInviteSharedContactClicked(@NonNull List choices); void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms); - void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId); + void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId); void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord); } } 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 6ef92e4bb2..e2c6dde271 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -5,6 +5,7 @@ import android.content.res.Configuration; import android.graphics.Canvas; import android.os.Build; import android.os.Bundle; +import android.text.Annotation; import android.text.Editable; import android.text.InputType; import android.text.Spannable; @@ -13,7 +14,6 @@ import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.TextUtils; import android.text.TextUtils.TruncateAt; -import android.text.method.QwertyKeyListener; import android.text.style.RelativeSizeSpan; import android.util.AttributeSet; import android.view.inputmethod.EditorInfo; @@ -21,7 +21,6 @@ import android.view.inputmethod.InputConnection; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.core.os.BuildCompat; import androidx.core.view.inputmethod.EditorInfoCompat; import androidx.core.view.inputmethod.InputConnectionCompat; import androidx.core.view.inputmethod.InputContentInfoCompat; @@ -30,18 +29,26 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.components.emoji.EmojiEditText; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +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.database.model.Mention; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.StringUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; +import org.thoughtcrime.securesms.util.ThemeUtil; -import java.util.UUID; +import java.util.List; + +import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER; public class ComposeText extends EmojiEditText { - private CharSequence hint; - private SpannableString subHint; + private CharSequence combinedHint; private MentionRendererDelegate mentionRendererDelegate; + private MentionValidatorWatcher mentionValidatorWatcher; @Nullable private InputPanel.MediaListener mediaListener; @Nullable private CursorPositionChangedListener cursorPositionChangedListener; @@ -62,47 +69,63 @@ public class ComposeText extends EmojiEditText { initialize(); } - public String getTextTrimmed(){ - return getText().toString().trim(); + /** + * Trims and returns text while preserving potential spans like {@link MentionAnnotation}. + */ + public @NonNull CharSequence getTextTrimmed() { + Editable text = getText(); + if (text == null) { + return ""; + } + return StringUtil.trimSequence(text); } @Override protected void onLayout(boolean changed, int left, int top, int right, int bottom) { super.onLayout(changed, left, top, right, bottom); - if (!TextUtils.isEmpty(hint)) { - if (!TextUtils.isEmpty(subHint)) { - setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint)) - .append("\n") - .append(ellipsizeToWidth(subHint))); - } else { - setHint(ellipsizeToWidth(hint)); - } + if (!TextUtils.isEmpty(combinedHint)) { + setHint(combinedHint); } } @Override - protected void onSelectionChanged(int selStart, int selEnd) { - super.onSelectionChanged(selStart, selEnd); + protected void onSelectionChanged(int selectionStart, int selectionEnd) { + super.onSelectionChanged(selectionStart, selectionEnd); - if (FeatureFlags.mentions()) { - if (selStart == selEnd) { - doAfterCursorChange(); + if (FeatureFlags.mentions() && getText() != null) { + boolean selectionChanged = changeSelectionForPartialMentions(getText(), selectionStart, selectionEnd); + if (selectionChanged) { + return; + } + + if (selectionStart == selectionEnd) { + doAfterCursorChange(getText()); } else { updateQuery(""); } } if (cursorPositionChangedListener != null) { - cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd); + cursorPositionChangedListener.onCursorPositionChanged(selectionStart, selectionEnd); } } @Override protected void onDraw(Canvas canvas) { - if (FeatureFlags.mentions() && getText() != null && getLayout() != null) { + if (getText() != null && getLayout() != null) { int checkpoint = canvas.save(); + + // Clip using same logic as TextView drawing + int maxScrollY = getLayout().getHeight() - getBottom() - getTop() - getCompoundPaddingBottom() - getCompoundPaddingTop(); + float clipLeft = getCompoundPaddingLeft() + getScrollX(); + float clipTop = (getScrollY() == 0) ? 0 : getExtendedPaddingTop() + getScrollY(); + float clipRight = getRight() - getLeft() - getCompoundPaddingRight() + getScrollX(); + float clipBottom = getBottom() - getTop() + getScrollY() - ((getScrollY() == maxScrollY) ? 0 : getExtendedPaddingBottom()); + + canvas.clipRect(clipLeft - 10, clipTop, clipRight + 10, clipBottom); canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop()); + try { mentionRendererDelegate.draw(canvas, getText(), getLayout()); } finally { @@ -120,25 +143,25 @@ public class ComposeText extends EmojiEditText { } public void setHint(@NonNull String hint, @Nullable CharSequence subHint) { - this.hint = hint; - if (subHint != null) { - this.subHint = new SpannableString(subHint); - this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + Spannable subHintSpannable = new SpannableString(subHint); + subHintSpannable.setSpan(new RelativeSizeSpan(0.5f), 0, subHintSpannable.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); + + combinedHint = new SpannableStringBuilder().append(ellipsizeToWidth(hint)) + .append("\n") + .append(ellipsizeToWidth(subHintSpannable)); } else { - this.subHint = null; + combinedHint = ellipsizeToWidth(hint); } - if (this.subHint != null) { - super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint)) - .append("\n") - .append(ellipsizeToWidth(this.subHint))); - } else { - super.setHint(ellipsizeToWidth(this.hint)); - } + super.setHint(combinedHint); } public void appendInvite(String invite) { + if (getText() == null) { + return; + } + if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) { append(" "); } @@ -155,13 +178,18 @@ public class ComposeText extends EmojiEditText { this.mentionQueryChangedListener = listener; } + public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) { + if (FeatureFlags.mentions()) { + mentionValidatorWatcher.setMentionValidator(mentionValidator); + } + } + private boolean isLandscape() { return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; } public void setTransport(TransportOption transport) { final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext()); - final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext()); int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND; int inputType = getInputType(); @@ -201,19 +229,59 @@ public class ComposeText extends EmojiEditText { this.mediaListener = mediaListener; } + public boolean hasMentions() { + Editable text = getText(); + if (text != null) { + return !MentionAnnotation.getMentionAnnotations(text).isEmpty(); + } + return false; + } + + public @NonNull List getMentions() { + return MentionAnnotation.getMentionsFromAnnotations(getText()); + } + private void initialize() { if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) { setImeOptions(getImeOptions() | 16777216); } + mentionRendererDelegate = new MentionRendererDelegate(getContext(), ThemeUtil.getThemedColor(getContext(), R.attr.conversation_mention_background_color)); + if (FeatureFlags.mentions()) { - mentionRendererDelegate = new MentionRendererDelegate(getContext()); + addTextChangedListener(new MentionDeleter()); + mentionValidatorWatcher = new MentionValidatorWatcher(); + addTextChangedListener(mentionValidatorWatcher); } } - private void doAfterCursorChange() { - Editable text = getText(); - if (text != null && enoughToFilter(text)) { + private boolean changeSelectionForPartialMentions(@NonNull Spanned spanned, int selectionStart, int selectionEnd) { + Annotation[] annotations = spanned.getSpans(0, spanned.length(), Annotation.class); + for (Annotation annotation : annotations) { + if (MentionAnnotation.isMentionAnnotation(annotation)) { + int spanStart = spanned.getSpanStart(annotation); + int spanEnd = spanned.getSpanEnd(annotation); + + boolean startInMention = selectionStart > spanStart && selectionStart < spanEnd; + boolean endInMention = selectionEnd > spanStart && selectionEnd < spanEnd; + + if (startInMention || endInMention) { + if (selectionStart == selectionEnd) { + setSelection(spanEnd, spanEnd); + } else { + int newStart = startInMention ? spanStart : selectionStart; + int newEnd = endInMention ? spanEnd : selectionEnd; + setSelection(newStart, newEnd); + } + return true; + } + } + } + return false; + } + + private void doAfterCursorChange(@NonNull Editable text) { + if (enoughToFilter(text)) { performFiltering(text); } else { updateQuery(""); @@ -241,7 +309,7 @@ public class ComposeText extends EmojiEditText { return end - findQueryStart(text, end) >= 1; } - public void replaceTextWithMention(@NonNull String displayName, @NonNull UUID uuid) { + public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) { Editable text = getText(); if (text == null) { return; @@ -251,14 +319,12 @@ public class ComposeText extends EmojiEditText { int end = getSelectionEnd(); int start = findQueryStart(text, end) - 1; - String original = TextUtils.substring(text, start, end); - QwertyKeyListener.markAsReplaced(text, start, end, original); - text.replace(start, end, createReplacementToken(displayName, uuid)); + text.replace(start, end, createReplacementToken(displayName, recipientId)); } - private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull UUID uuid) { - SpannableStringBuilder builder = new SpannableStringBuilder("@"); + private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) { + SpannableStringBuilder builder = new SpannableStringBuilder().append(MENTION_STARTER); if (text instanceof Spanned) { SpannableString spannableString = new SpannableString(text + " "); TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0); @@ -267,7 +333,7 @@ public class ComposeText extends EmojiEditText { builder.append(text).append(" "); } - builder.setSpan(MentionAnnotation.mentionAnnotationForUuid(uuid), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + builder.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(recipientId), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); return builder; } @@ -278,11 +344,11 @@ public class ComposeText extends EmojiEditText { } int delimiterSearchIndex = inputCursorPosition - 1; - while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != '@' && text.charAt(delimiterSearchIndex) != ' ')) { + while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != MENTION_STARTER && text.charAt(delimiterSearchIndex) != ' ')) { delimiterSearchIndex--; } - if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == '@') { + if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == MENTION_STARTER) { return delimiterSearchIndex + 1; } return inputCursorPosition; @@ -300,7 +366,7 @@ public class ComposeText extends EmojiEditText { @Override public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { - if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { + if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) { try { inputContentInfo.requestPermission(); } catch (Exception e) { 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 f8f577a810..16c46c8835 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -2,10 +2,8 @@ package org.thoughtcrime.securesms.components; import android.animation.Animator; import android.animation.ValueAnimator; -import android.annotation.TargetApi; import android.content.Context; import android.net.Uri; -import android.os.Build; import android.text.format.DateUtils; import android.util.AttributeSet; import android.view.KeyEvent; @@ -94,7 +92,6 @@ public class InputPanel extends LinearLayout super(context, attrs); } - @TargetApi(Build.VERSION_CODES.HONEYCOMB) public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @@ -160,7 +157,7 @@ public class InputPanel extends LinearLayout public void setQuote(@NonNull GlideRequests glideRequests, long id, @NonNull Recipient author, - @NonNull String body, + @NonNull CharSequence body, @NonNull SlideDeck attachments) { this.quoteView.setQuote(glideRequests, id, author, body, false, attachments); @@ -228,7 +225,7 @@ 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(), false, quoteView.getAttachments())); + return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions())); } else { return Optional.absent(); } 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 50d8868ebf..d2b89606f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -23,6 +23,8 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.Slide; @@ -55,7 +57,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { private long id; private LiveRecipient author; - private String body; + private CharSequence body; private TextView mediaDescriptionText; private TextView missingLinkText; private SlideDeck attachments; @@ -147,7 +149,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { public void setQuote(GlideRequests glideRequests, long id, @NonNull Recipient author, - @Nullable String body, + @Nullable CharSequence body, boolean originalMissing, @NonNull SlideDeck attachments) { @@ -196,7 +198,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing)); } - private void setQuoteText(@Nullable String body, @NonNull SlideDeck attachments) { + private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) { if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) { bodyView.setVisibility(VISIBLE); bodyView.setText(body == null ? "" : body); @@ -280,11 +282,15 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver { return author.get(); } - public String getBody() { + public CharSequence getBody() { return body; } public List getAttachments() { return attachments.asAttachments(); } + + public @NonNull List getMentions() { + return MentionAnnotation.getMentionsFromAnnotations(body); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java index e6779c330e..a1147ff94e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/emoji/EmojiTextView.java @@ -2,12 +2,17 @@ package org.thoughtcrime.securesms.components.emoji; import android.content.Context; import android.content.res.TypedArray; +import android.graphics.Canvas; import android.graphics.drawable.Drawable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.core.widget.TextViewCompat; import androidx.appcompat.widget.AppCompatTextView; + +import android.text.Annotation; import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.TextUtils; import android.util.AttributeSet; import android.util.TypedValue; @@ -15,10 +20,15 @@ import android.util.TypedValue; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable; import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; +import java.util.List; + public class EmojiTextView extends AppCompatTextView { @@ -35,6 +45,9 @@ public class EmojiTextView extends AppCompatTextView { private int maxLength; private CharSequence overflowText; private CharSequence previousOverflowText; + private boolean renderMentions; + + private MentionRendererDelegate mentionRendererDelegate; public EmojiTextView(Context context) { this(context, null); @@ -48,14 +61,33 @@ public class EmojiTextView extends AppCompatTextView { super(context, attrs, defStyleAttr); TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0); - scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false); - maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1); - forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false); + scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false); + maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1); + forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false); + renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true); a.recycle(); a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize}); originalFontSize = a.getDimensionPixelSize(0, 0); a.recycle(); + + if (renderMentions) { + mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20)); + } + } + + @Override + protected void onDraw(Canvas canvas) { + if (renderMentions && getText() instanceof Spanned && getLayout() != null) { + int checkpoint = canvas.save(); + canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop()); + try { + mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout()); + } finally { + canvas.restoreToCount(checkpoint); + } + } + super.onDraw(canvas); } @Override public void setText(@Nullable CharSequence text, BufferType type) { @@ -115,7 +147,19 @@ public class EmojiTextView extends AppCompatTextView { private void ellipsizeAnyTextForMaxLength() { if (maxLength > 0 && getText().length() > maxLength + 1) { SpannableStringBuilder newContent = new SpannableStringBuilder(); - newContent.append(getText().subSequence(0, maxLength)).append(ELLIPSIS).append(Optional.fromNullable(overflowText).or("")); + + CharSequence shortenedText = getText().subSequence(0, maxLength); + if (shortenedText instanceof Spanned) { + Spanned spanned = (Spanned) shortenedText; + List mentionAnnotations = MentionAnnotation.getMentionAnnotations(spanned, maxLength - 1, maxLength); + if (!mentionAnnotations.isEmpty()) { + shortenedText = shortenedText.subSequence(0, spanned.getSpanStart(mentionAnnotations.get(0))); + } + } + + newContent.append(shortenedText) + .append(ELLIPSIS) + .append(Util.emptyIfNull(overflowText)); EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java index ed7af5a7a1..bc30322c86 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionAnnotation.java @@ -2,16 +2,26 @@ package org.thoughtcrime.securesms.components.mention; import android.text.Annotation; +import android.text.Spannable; +import android.text.Spanned; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; -import java.util.UUID; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Collections; +import java.util.List; /** - * Factory for creating mention annotation spans. + * This wraps an Android standard {@link Annotation} so it can leverage the built in + * span parceling for copy/paste. The annotation span contains the mentioned recipient's + * id (in numerical form). * - * Note: This wraps creating an Android standard {@link Annotation} so it can leverage the built in - * span parceling for copy/paste. Do not extend Annotation or this will be lost. + * Note: Do not extend Annotation or the parceling behavior will be lost. */ public final class MentionAnnotation { @@ -20,7 +30,45 @@ public final class MentionAnnotation { private MentionAnnotation() { } - public static Annotation mentionAnnotationForUuid(@NonNull UUID uuid) { - return new Annotation(MENTION_ANNOTATION, uuid.toString()); + public static Annotation mentionAnnotationForRecipientId(@NonNull RecipientId id) { + return new Annotation(MENTION_ANNOTATION, idToMentionAnnotationValue(id)); + } + + public static String idToMentionAnnotationValue(@NonNull RecipientId id) { + return String.valueOf(id.toLong()); + } + + public static boolean isMentionAnnotation(@NonNull Annotation annotation) { + return MENTION_ANNOTATION.equals(annotation.getKey()); + } + + public static void setMentionAnnotations(Spannable body, List mentions) { + for (Mention mention : mentions) { + body.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(mention.getRecipientId()), mention.getStart(), mention.getStart() + mention.getLength(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + public static @NonNull List getMentionsFromAnnotations(@Nullable CharSequence text) { + if (text instanceof Spanned) { + Spanned spanned = (Spanned) text; + return Stream.of(getMentionAnnotations(spanned)) + .map(annotation -> { + int spanStart = spanned.getSpanStart(annotation); + int spanLength = spanned.getSpanEnd(annotation) - spanStart; + return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength); + }) + .toList(); + } + return Collections.emptyList(); + } + + public static @NonNull List getMentionAnnotations(@NonNull Spanned spanned) { + return getMentionAnnotations(spanned, 0, spanned.length()); + } + + public static @NonNull List getMentionAnnotations(@NonNull Spanned spanned, int start, int end) { + return Stream.of(spanned.getSpans(start, end, Annotation.class)) + .filter(MentionAnnotation::isMentionAnnotation) + .toList(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionDeleter.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionDeleter.java new file mode 100644 index 0000000000..db5257ede2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionDeleter.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.components.mention; + +import android.text.Annotation; +import android.text.Editable; +import android.text.Spanned; +import android.text.TextWatcher; + +import androidx.annotation.Nullable; + +import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER; + +/** + * Detects if some part of the mention is being deleted, and if so, deletes the entire mention and + * span from the text view. + */ +public class MentionDeleter implements TextWatcher { + + @Nullable private Annotation toDelete; + + @Override + public void beforeTextChanged(CharSequence sequence, int start, int count, int after) { + if (count > 0 && sequence instanceof Spanned) { + Spanned text = (Spanned) sequence; + + for (Annotation annotation : MentionAnnotation.getMentionAnnotations(text, start, start + count)) { + if (text.getSpanStart(annotation) < start && text.getSpanEnd(annotation) > start) { + toDelete = annotation; + return; + } + } + } + } + + @Override + public void afterTextChanged(Editable editable) { + if (toDelete == null) { + return; + } + + int toDeleteStart = editable.getSpanStart(toDelete); + int toDeleteEnd = editable.getSpanEnd(toDelete); + editable.removeSpan(toDelete); + toDelete = null; + + editable.replace(toDeleteStart, toDeleteEnd, String.valueOf(MENTION_STARTER)); + } + + @Override + public void onTextChanged(CharSequence sequence, int start, int before, int count) { } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java index 69f50f55df..76984d63b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionRendererDelegate.java @@ -9,11 +9,11 @@ import android.text.Spanned; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; +import androidx.annotation.Px; import androidx.core.content.ContextCompat; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.DrawableUtil; -import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.ViewUtil; /** @@ -28,42 +28,32 @@ public class MentionRendererDelegate { private final MentionRenderer multi; private final int horizontalPadding; - public MentionRendererDelegate(@NonNull Context context) { - //noinspection ConstantConditions - this(ViewUtil.dpToPx(2), - ViewUtil.dpToPx(2), - ContextCompat.getDrawable(context, R.drawable.mention_text_bg), - ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left), - ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid), - ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right), - ThemeUtil.getThemedColor(context, R.attr.conversation_mention_background_color)); - } + public MentionRendererDelegate(@NonNull Context context, @ColorInt int tint) { + this.horizontalPadding = ViewUtil.dpToPx(2); - public MentionRendererDelegate(int horizontalPadding, - int verticalPadding, - @NonNull Drawable drawable, - @NonNull Drawable drawableLeft, - @NonNull Drawable drawableMid, - @NonNull Drawable drawableEnd, - @ColorInt int tint) - { - this.horizontalPadding = horizontalPadding; - single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding, - verticalPadding, - DrawableUtil.tint(drawable, tint)); - multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding, - verticalPadding, - DrawableUtil.tint(drawableLeft, tint), - DrawableUtil.tint(drawableMid, tint), - DrawableUtil.tint(drawableEnd, tint)); + Drawable drawable = ContextCompat.getDrawable(context, R.drawable.mention_text_bg); + Drawable drawableLeft = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left); + Drawable drawableMid = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid); + Drawable drawableEnd = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right); + + //noinspection ConstantConditions + single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding, + 0, + DrawableUtil.tint(drawable, tint)); + //noinspection ConstantConditions + multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding, + 0, + DrawableUtil.tint(drawableLeft, tint), + DrawableUtil.tint(drawableMid, tint), + DrawableUtil.tint(drawableEnd, tint)); } public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) { - Annotation[] spans = text.getSpans(0, text.length(), Annotation.class); - for (Annotation span : spans) { - if (MentionAnnotation.MENTION_ANNOTATION.equals(span.getKey())) { - int spanStart = text.getSpanStart(span); - int spanEnd = text.getSpanEnd(span); + Annotation[] annotations = text.getSpans(0, text.length(), Annotation.class); + for (Annotation annotation : annotations) { + if (MentionAnnotation.isMentionAnnotation(annotation)) { + int spanStart = text.getSpanStart(annotation); + int spanEnd = text.getSpanEnd(annotation); int startLine = layout.getLineForOffset(spanStart); int endLine = layout.getLineForOffset(spanEnd); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionValidatorWatcher.java b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionValidatorWatcher.java new file mode 100644 index 0000000000..3e9e84b392 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/mention/MentionValidatorWatcher.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.components.mention; + +import android.text.Annotation; +import android.text.Editable; +import android.text.Spanned; +import android.text.TextWatcher; + +import androidx.annotation.Nullable; + +import java.util.List; + +/** + * Provides a mechanism to validate mention annotations set on an edit text. This enables + * removing invalid mentions if the user mentioned isn't in the group. + */ +public class MentionValidatorWatcher implements TextWatcher { + + @Nullable private List invalidMentionAnnotations; + @Nullable private MentionValidator mentionValidator; + + @Override + public void onTextChanged(CharSequence sequence, int start, int before, int count) { + if (count > 1 && mentionValidator != null && sequence instanceof Spanned) { + Spanned span = (Spanned) sequence; + + List mentionAnnotations = MentionAnnotation.getMentionAnnotations(span, start, start + count); + + if (mentionAnnotations.size() > 0) { + invalidMentionAnnotations = mentionValidator.getInvalidMentionAnnotations(mentionAnnotations); + } + } + } + + @Override + public void afterTextChanged(Editable editable) { + if (invalidMentionAnnotations == null) { + return; + } + + List invalidMentions = invalidMentionAnnotations; + invalidMentionAnnotations = null; + + for (Annotation annotation : invalidMentions) { + editable.removeSpan(annotation); + } + } + + public void setMentionValidator(@Nullable MentionValidator mentionValidator) { + this.mentionValidator = mentionValidator; + } + + @Override + public void beforeTextChanged(CharSequence sequence, int start, int count, int after) { } + + public interface MentionValidator { + List getInvalidMentionAnnotations(List mentionAnnotations); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index 890bcad015..7a8ed8e357 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -41,6 +41,8 @@ import android.provider.Browser; import android.provider.ContactsContract; import android.provider.Telephony; import android.text.Editable; +import android.text.Spannable; +import android.text.SpannableString; import android.text.TextWatcher; import android.view.Gravity; import android.view.KeyEvent; @@ -72,6 +74,7 @@ import androidx.core.content.pm.ShortcutManagerCompat; import androidx.core.graphics.drawable.IconCompat; import androidx.lifecycle.ViewModelProviders; +import com.annimon.stream.Collectors; import com.annimon.stream.Stream; import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.request.target.CustomTarget; @@ -111,6 +114,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiStrings; import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView; import org.thoughtcrime.securesms.components.location.SignalPlace; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder; import org.thoughtcrime.securesms.components.reminder.Reminder; import org.thoughtcrime.securesms.components.reminder.ReminderView; @@ -124,6 +128,7 @@ import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity; import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState; +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; import org.thoughtcrime.securesms.conversationlist.model.MessageResult; @@ -136,12 +141,14 @@ import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; -import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.MentionUtil; +import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions; import org.thoughtcrime.securesms.database.MmsSmsColumns.Types; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.identity.IdentityRecordList; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; @@ -196,7 +203,6 @@ import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.StickerSlide; import org.thoughtcrime.securesms.mms.VideoSlide; -import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.GroupShareProfileView; @@ -221,6 +227,7 @@ import org.thoughtcrime.securesms.stickers.StickerLocator; import org.thoughtcrime.securesms.stickers.StickerManagementActivity; import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent; import org.thoughtcrime.securesms.stickers.StickerSearchRepository; +import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; import org.thoughtcrime.securesms.util.CommunicationActions; @@ -248,10 +255,12 @@ import org.whispersystems.libsignal.util.Pair; import org.whispersystems.libsignal.util.guava.Optional; import java.io.IOException; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -648,6 +657,7 @@ public class ConversationActivity extends PassphraseRequiredActivity boolean initiating = threadId == -1; QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull(); SlideDeck slideDeck = new SlideDeck(); + List mentions = new ArrayList<>(result.getMentions()); for (Media mediaItem : result.getNonUploadedMedia()) { if (MediaUtil.isVideoType(mediaItem.getMimeType())) { @@ -669,6 +679,7 @@ public class ConversationActivity extends PassphraseRequiredActivity quote, Collections.emptyList(), Collections.emptyList(), + mentions, expiresIn, result.isViewOnce(), subscriptionId, @@ -1373,7 +1384,7 @@ public class ConversationActivity extends PassphraseRequiredActivity private ListenableFuture initializeDraft() { final SettableFuture result = new SettableFuture<>(); - final String draftText = getIntent().getStringExtra(TEXT_EXTRA); + final CharSequence draftText = getIntent().getCharSequenceExtra(TEXT_EXTRA); final Uri draftMedia = getIntent().getData(); final String draftContentType = getIntent().getType(); final MediaType draftMediaType = MediaType.from(draftContentType); @@ -1437,19 +1448,34 @@ public class ConversationActivity extends PassphraseRequiredActivity private ListenableFuture initializeDraftFromDatabase() { SettableFuture future = new SettableFuture<>(); - new AsyncTask>() { + new AsyncTask>() { @Override - protected List doInBackground(Void... params) { - DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this); - List results = draftDatabase.getDrafts(threadId); + protected Pair doInBackground(Void... params) { + Context context = ConversationActivity.this; + DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(context); + Drafts results = draftDatabase.getDrafts(threadId); + Draft mentionsDraft = results.getDraftOfType(Draft.MENTION); + Spannable updatedText = null; + + if (mentionsDraft != null) { + String text = results.getDraftOfType(Draft.TEXT).getValue(); + List mentions = MentionUtil.bodyRangeListToMentions(context, Base64.decodeOrThrow(mentionsDraft.getValue())); + UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, text, mentions); + + updatedText = new SpannableString(updated.getBody()); + MentionAnnotation.setMentionAnnotations(updatedText, updated.getMentions()); + } draftDatabase.clearDrafts(threadId); - return results; + return new Pair<>(results, updatedText); } @Override - protected void onPostExecute(List drafts) { + protected void onPostExecute(Pair draftsWithUpdatedMentions) { + Drafts drafts = Objects.requireNonNull(draftsWithUpdatedMentions.first()); + CharSequence updatedText = draftsWithUpdatedMentions.second(); + if (drafts.isEmpty()) { future.set(false); updateToggleButtonState(); @@ -1473,7 +1499,7 @@ public class ConversationActivity extends PassphraseRequiredActivity try { switch (draft.getType()) { case Draft.TEXT: - composeText.setText(draft.getValue()); + composeText.setText(updatedText == null ? draft.getValue() : updatedText); listener.onSuccess(true); break; case Draft.LOCATION: @@ -1874,8 +1900,9 @@ public class ConversationActivity extends PassphraseRequiredActivity MentionsPickerViewModel mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class); recipient.observe(this, mentionsViewModel::onRecipientChange); + composeText.setMentionQueryChangedListener(query -> { - if (getRecipient().isGroup()) { + if (getRecipient().isPushV2Group()) { if (!mentionsSuggestions.resolved()) { mentionsSuggestions.get(); } @@ -1883,12 +1910,26 @@ public class ConversationActivity extends PassphraseRequiredActivity } }); + composeText.setMentionValidator(annotations -> { + if (!getRecipient().isPushV2Group()) { + return annotations; + } + + Set validRecipientIds = Stream.of(getRecipient().getParticipants()) + .map(r -> MentionAnnotation.idToMentionAnnotationValue(r.getId())) + .collect(Collectors.toSet()); + + return Stream.of(annotations) + .filterNot(a -> validRecipientIds.contains(a.getValue())) + .toList(); + }); + mentionsViewModel.getSelectedRecipient().observe(this, recipient -> { String replacementDisplayName = recipient.getDisplayName(this); if (replacementDisplayName.equals(recipient.getDisplayUsername())) { replacementDisplayName = recipient.getUsername().or(replacementDisplayName); } - composeText.replaceTextWithMention(replacementDisplayName, recipient.requireUuid()); + composeText.replaceTextWithMention(replacementDisplayName, recipient.getId()); }); } @@ -2073,7 +2114,7 @@ public class ConversationActivity extends PassphraseRequiredActivity long expiresIn = recipient.get().getExpireMessages() * 1000L; boolean initiating = threadId == -1; - sendMediaMessage(isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), expiresIn, false, subscriptionId, initiating, false); + sendMediaMessage(isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, false); } private void selectContactInfo(ContactData contactData) { @@ -2097,7 +2138,11 @@ public class ConversationActivity extends PassphraseRequiredActivity Drafts drafts = new Drafts(); if (!Util.isEmpty(composeText)) { - drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed())); + drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed().toString())); + List draftMentions = composeText.getMentions(); + if (!draftMentions.isEmpty()) { + drafts.add(new Draft(Draft.MENTION, Base64.encodeBytes(MentionUtil.mentionsToBodyRangeList(draftMentions).toByteArray()))); + } } for (Slide slide : attachmentManager.buildSlideDeck().getSlides()) { @@ -2187,7 +2232,7 @@ public class ConversationActivity extends PassphraseRequiredActivity } private void calculateCharactersRemaining() { - String messageBody = composeText.getTextTrimmed(); + String messageBody = composeText.getTextTrimmed().toString(); TransportOption transportOption = sendButton.getSelectedTransport(); CharacterState characterState = transportOption.calculateCharacters(messageBody); @@ -2270,7 +2315,7 @@ public class ConversationActivity extends PassphraseRequiredActivity } private String getMessage() throws InvalidMessageException { - String rawText = composeText.getTextTrimmed(); + String rawText = composeText.getTextTrimmed().toString(); if (rawText.length() < 1 && !attachmentManager.isAttachmentPresent()) throw new InvalidMessageException(getString(R.string.ConversationActivity_message_is_empty_exclamation)); @@ -2339,6 +2384,7 @@ public class ConversationActivity extends PassphraseRequiredActivity recipient.isGroup() || recipient.getEmail().isPresent() || inputPanel.getQuote().isPresent() || + composeText.hasMentions() || linkPreviewViewModel.hasLinkPreview() || needsSplit; @@ -2369,9 +2415,10 @@ public class ConversationActivity extends PassphraseRequiredActivity private void sendMediaMessage(@NonNull MediaSendActivityResult result) { long expiresIn = recipient.get().getExpireMessages() * 1000L; QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull(); + List mentions = new ArrayList<>(result.getMentions()); boolean initiating = threadId == -1; - OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, quote, Collections.emptyList(), Collections.emptyList()); - OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message ); + OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, quote, Collections.emptyList(), Collections.emptyList(), mentions); + OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message); ApplicationContext.getInstance(this).getTypingStatusSender().onTypingStopped(threadId); @@ -2395,7 +2442,18 @@ public class ConversationActivity extends PassphraseRequiredActivity throws InvalidMessageException { Log.i(TAG, "Sending media message..."); - sendMediaMessage(forceSms, getMessage(), attachmentManager.buildSlideDeck(), inputPanel.getQuote().orNull(), Collections.emptyList(), linkPreviewViewModel.getActiveLinkPreviews(), expiresIn, viewOnce, subscriptionId, initiating, true); + sendMediaMessage(forceSms, + getMessage(), + attachmentManager.buildSlideDeck(), + inputPanel.getQuote().orNull(), + Collections.emptyList(), + linkPreviewViewModel.getActiveLinkPreviews(), + composeText.getMentions(), + expiresIn, + viewOnce, + subscriptionId, + initiating, + true); } private ListenableFuture sendMediaMessage(final boolean forceSms, @@ -2404,6 +2462,7 @@ public class ConversationActivity extends PassphraseRequiredActivity QuoteModel quote, List contacts, List previews, + List mentions, final long expiresIn, final boolean viewOnce, final int subscriptionId, @@ -2424,7 +2483,7 @@ public class ConversationActivity extends PassphraseRequiredActivity } } - OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient.get(), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews); + OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient.get(), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions); final SettableFuture future = new SettableFuture<>(); final Context context = getApplicationContext(); @@ -2543,7 +2602,7 @@ public class ConversationActivity extends PassphraseRequiredActivity private void updateLinkPreviewState() { if (TextSecurePreferences.isLinkPreviewsEnabled(this) && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) { linkPreviewViewModel.onEnabled(); - linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed(), composeText.getSelectionStart(), composeText.getSelectionEnd()); + linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed().toString(), composeText.getSelectionStart(), composeText.getSelectionEnd()); } else { linkPreviewViewModel.onUserCancel(); } @@ -2611,7 +2670,20 @@ public class ConversationActivity extends PassphraseRequiredActivity SlideDeck slideDeck = new SlideDeck(); slideDeck.addSlide(audioSlide); - sendMediaMessage(forceSms, "", slideDeck, inputPanel.getQuote().orNull(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, true).addListener(new AssertedSuccessListener() { + ListenableFuture sendResult = sendMediaMessage(forceSms, + "", + slideDeck, + inputPanel.getQuote().orNull(), + Collections.emptyList(), + Collections.emptyList(), + composeText.getMentions(), + expiresIn, + false, + subscriptionId, + initiating, + true); + + sendResult.addListener(new AssertedSuccessListener() { @Override public void onSuccess(Void nothing) { new AsyncTask() { @@ -2700,7 +2772,7 @@ public class ConversationActivity extends PassphraseRequiredActivity @Override public void onCursorPositionChanged(int start, int end) { - linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed(), start, end); + linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed().toString(), start, end); } @Override @@ -2740,7 +2812,7 @@ public class ConversationActivity extends PassphraseRequiredActivity slideDeck.addSlide(stickerSlide); - sendMediaMessage(transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, clearCompose); + sendMediaMessage(transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, clearCompose); } private void silentlySetComposeText(String text) { @@ -2969,7 +3041,9 @@ public class ConversationActivity extends PassphraseRequiredActivity } @Override - public void handleReplyMessage(MessageRecord messageRecord) { + public void handleReplyMessage(ConversationMessage conversationMessage) { + MessageRecord messageRecord = conversationMessage.getMessageRecord(); + Recipient author; if (messageRecord.isOutgoing()) { @@ -3005,7 +3079,7 @@ public class ConversationActivity extends PassphraseRequiredActivity inputPanel.setQuote(GlideApp.with(this), messageRecord.getDateSent(), author, - messageRecord.getBody(), + conversationMessage.getDisplayBody(this), slideDeck); } else { SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck(); @@ -3019,7 +3093,7 @@ public class ConversationActivity extends PassphraseRequiredActivity inputPanel.setQuote(GlideApp.with(this), messageRecord.getDateSent(), author, - messageRecord.getBody(), + conversationMessage.getDisplayBody(this), slideDeck); } @@ -3186,6 +3260,7 @@ public class ConversationActivity extends PassphraseRequiredActivity quote, Collections.emptyList(), Collections.emptyList(), + composeText.getMentions(), expiresIn, false, subscriptionId, @@ -3244,7 +3319,7 @@ public class ConversationActivity extends PassphraseRequiredActivity } } - private class QuoteRestorationTask extends AsyncTask { + private class QuoteRestorationTask extends AsyncTask { private final String serialized; private final SettableFuture future; @@ -3255,20 +3330,27 @@ public class ConversationActivity extends PassphraseRequiredActivity } @Override - protected MessageRecord doInBackground(Void... voids) { + protected ConversationMessage doInBackground(Void... voids) { QuoteId quoteId = QuoteId.deserialize(ConversationActivity.this, serialized); - if (quoteId != null) { - return DatabaseFactory.getMmsSmsDatabase(getApplicationContext()).getMessageFor(quoteId.getId(), quoteId.getAuthor()); + if (quoteId == null) { + return null; } - return null; + Context context = getApplicationContext(); + + MessageRecord messageRecord = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(quoteId.getId(), quoteId.getAuthor()); + if (messageRecord == null) { + return null; + } + + return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord); } @Override - protected void onPostExecute(MessageRecord messageRecord) { - if (messageRecord != null) { - handleReplyMessage(messageRecord); + protected void onPostExecute(ConversationMessage conversationMessage) { + if (conversationMessage != null) { + handleReplyMessage(conversationMessage); future.set(true); } else { Log.e(TAG, "Failed to restore a quote from a draft. No matching message record."); 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 ace0e59c1e..5ebc3c9402 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -4,14 +4,17 @@ import android.content.Context; import android.database.ContentObserver; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.paging.DataSource; import androidx.paging.PositionalDataSource; import com.annimon.stream.Stream; +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.database.DatabaseContentProviders; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; @@ -19,7 +22,11 @@ import org.thoughtcrime.securesms.util.paging.Invalidator; import org.thoughtcrime.securesms.util.paging.SizeFixResult; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; import java.util.List; +import java.util.Map; import java.util.concurrent.Executor; /** @@ -66,19 +73,24 @@ class ConversationDataSource extends PositionalDataSource { int totalCount = db.getConversationCount(threadId); int effectiveCount = params.requestedStartPosition; + MentionHelper mentionHelper = new MentionHelper(); + try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.requestedStartPosition, params.requestedLoadSize))) { MessageRecord record; while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) { records.add(record); + mentionHelper.add(record); effectiveCount++; } } + mentionHelper.fetchMentions(context); + if (!isInvalid()) { SizeFixResult result = SizeFixResult.ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount); List items = Stream.of(result.getItems()) - .map(ConversationMessage::new) + .map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId()))) .toList(); callback.onResult(items, params.requestedStartPosition, result.getTotal()); @@ -92,24 +104,48 @@ class ConversationDataSource extends PositionalDataSource { public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback callback) { long start = System.currentTimeMillis(); - MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context); - List records = new ArrayList<>(params.loadSize); + MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context); + List records = new ArrayList<>(params.loadSize); + MentionHelper mentionHelper = new MentionHelper(); try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.startPosition, params.loadSize))) { MessageRecord record; while ((record = reader.getNext()) != null && !isInvalid()) { records.add(record); + mentionHelper.add(record); } } + mentionHelper.fetchMentions(context); + List items = Stream.of(records) - .map(ConversationMessage::new) + .map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId()))) .toList(); callback.onResult(items); Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.startPosition + ", size: " + params.loadSize + (isInvalid() ? " -- invalidated" : "")); } + private static class MentionHelper { + + private Collection messageIds = new LinkedList<>(); + private Map> messageIdToMentions = new HashMap<>(); + + void add(MessageRecord record) { + if (record.isMms()) { + messageIds.add(record.getId()); + } + } + + void fetchMentions(Context context) { + messageIdToMentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessages(messageIds); + } + + @Nullable List getMentions(long id) { + return messageIdToMentions.get(id); + } + } + static class Factory extends DataSource.Factory { private final Context context; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 0d7cd7c08b..542fa0b084 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -18,6 +18,8 @@ package org.thoughtcrime.securesms.conversation; import android.annotation.SuppressLint; import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; @@ -25,7 +27,7 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; -import android.text.ClipboardManager; +import android.text.SpannableStringBuilder; import android.text.TextUtils; import android.view.LayoutInflater; import android.view.Menu; @@ -72,6 +74,7 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity; import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener; import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder; +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MessagingDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; @@ -128,7 +131,6 @@ import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collections; -import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -595,33 +597,25 @@ public class ConversationFragment extends LoggingFragment { } private void handleCopyMessage(final Set conversationMessages) { - List messageList = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).toList(); - Collections.sort(messageList, new Comparator() { - @Override - public int compare(MessageRecord lhs, MessageRecord rhs) { - if (lhs.getDateReceived() < rhs.getDateReceived()) return -1; - else if (lhs.getDateReceived() == rhs.getDateReceived()) return 0; - else return 1; - } - }); + List messageList = new ArrayList<>(conversationMessages); + Collections.sort(messageList, (lhs, rhs) -> Long.compare(lhs.getMessageRecord().getDateReceived(), rhs.getMessageRecord().getDateReceived())); - StringBuilder bodyBuilder = new StringBuilder(); - ClipboardManager clipboard = (ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + SpannableStringBuilder bodyBuilder = new SpannableStringBuilder(); + ClipboardManager clipboard = (ClipboardManager) requireActivity().getSystemService(Context.CLIPBOARD_SERVICE); - for (MessageRecord messageRecord : messageList) { - String body = messageRecord.getDisplayBody(requireContext()).toString(); + for (ConversationMessage message : messageList) { + CharSequence body = message.getDisplayBody(requireContext()); if (!TextUtils.isEmpty(body)) { - bodyBuilder.append(body).append('\n'); + if (bodyBuilder.length() > 0) { + bodyBuilder.append('\n'); + } + bodyBuilder.append(body); } } - if (bodyBuilder.length() > 0 && bodyBuilder.charAt(bodyBuilder.length() - 1) == '\n') { - bodyBuilder.deleteCharAt(bodyBuilder.length() - 1); + + if (!TextUtils.isEmpty(bodyBuilder)) { + clipboard.setPrimaryClip(ClipData.newPlainText(null, bodyBuilder)); } - - String result = bodyBuilder.toString(); - - if (!TextUtils.isEmpty(result)) - clipboard.setText(result); } private void handleDeleteMessages(final Set conversationMessages) { @@ -746,8 +740,7 @@ public class ConversationFragment extends LoggingFragment { } private void handleForwardMessage(ConversationMessage conversationMessage) { - MessageRecord message = conversationMessage.getMessageRecord(); - if (message.isViewOnce()) { + if (conversationMessage.getMessageRecord().isViewOnce()) { throw new AssertionError("Cannot forward a view-once message."); } @@ -755,10 +748,10 @@ public class ConversationFragment extends LoggingFragment { SimpleTask.run(getLifecycle(), () -> { Intent composeIntent = new Intent(getActivity(), ShareActivity.class); - composeIntent.putExtra(Intent.EXTRA_TEXT, message.getDisplayBody(requireContext()).toString()); + composeIntent.putExtra(Intent.EXTRA_TEXT, conversationMessage.getDisplayBody(requireContext())); - if (message.isMms()) { - MmsMessageRecord mediaMessage = (MmsMessageRecord) message; + if (conversationMessage.getMessageRecord().isMms()) { + MmsMessageRecord mediaMessage = (MmsMessageRecord) conversationMessage.getMessageRecord(); boolean isAlbum = mediaMessage.containsMediaSlide() && mediaMessage.getSlideDeck().getSlides().size() > 1 && mediaMessage.getSlideDeck().getAudioSlide() == null && @@ -788,7 +781,7 @@ public class ConversationFragment extends LoggingFragment { Optional.fromNullable(attachment.getCaption()), Optional.absent())); } - }; + } if (!mediaList.isEmpty()) { composeIntent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaList); @@ -835,7 +828,7 @@ public class ConversationFragment extends LoggingFragment { ((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView(); } - listener.handleReplyMessage(message.getMessageRecord()); + listener.handleReplyMessage(message); } private void handleSaveAttachment(final MediaMmsMessageRecord message) { @@ -875,7 +868,7 @@ public class ConversationFragment extends LoggingFragment { if (getListAdapter() != null) { clearHeaderIfNotTyping(getListAdapter()); setLastSeen(0); - getListAdapter().addFastRecord(new ConversationMessage(messageRecord)); + getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord, messageRecord.getDisplayBody(requireContext()), message.getMentions())); list.post(() -> list.scrollToPosition(0)); } @@ -888,7 +881,7 @@ public class ConversationFragment extends LoggingFragment { if (getListAdapter() != null) { clearHeaderIfNotTyping(getListAdapter()); setLastSeen(0); - getListAdapter().addFastRecord(new ConversationMessage(messageRecord)); + getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord)); list.post(() -> list.scrollToPosition(0)); } @@ -1017,7 +1010,7 @@ public class ConversationFragment extends LoggingFragment { public interface ConversationFragmentListener { void setThreadId(long threadId); - void handleReplyMessage(MessageRecord messageRecord); + void handleReplyMessage(ConversationMessage conversationMessage); void onMessageActionToolbarOpened(); void onForwardClicked(); void onMessageRequest(@NonNull MessageRequestViewModel viewModel); @@ -1306,7 +1299,7 @@ public class ConversationFragment extends LoggingFragment { } @Override - public void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) { + public void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) { if (getContext() == null) return; RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 146a6ed953..23b6b94355 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -26,6 +26,7 @@ import android.graphics.PorterDuff; import android.graphics.Rect; import android.graphics.Typeface; import android.net.Uri; +import android.text.Annotation; import android.text.Spannable; import android.text.SpannableString; import android.text.SpannableStringBuilder; @@ -71,6 +72,7 @@ import org.thoughtcrime.securesms.components.QuoteView; import org.thoughtcrime.securesms.components.SharedContactView; import org.thoughtcrime.securesms.components.BorderlessImageView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.DatabaseFactory; @@ -552,7 +554,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati } else if (isCaptionlessMms(messageRecord)) { bodyText.setVisibility(View.GONE); } else { - Spannable styledText = linkifyMessageBody(messageRecord.getDisplayBody(getContext()), batchSelected.isEmpty()); + Spannable styledText = linkifyMessageBody(conversationMessage.getDisplayBody(getContext()), batchSelected.isEmpty()); styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery); styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery); @@ -855,7 +857,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati contactPhoto.setOnClickListener(v -> { if (eventListener != null) { - eventListener.onGroupMemberAvatarClicked(recipientId, conversationRecipient.get().requireGroupId()); + eventListener.onGroupMemberClicked(recipientId, conversationRecipient.get().requireGroupId()); } }); @@ -879,6 +881,12 @@ public class ConversationItem extends LinearLayout implements BindableConversati messageBody.setSpan(new LongClickCopySpan(urlSpan.getURL()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } + + List mentionAnnotations = MentionAnnotation.getMentionAnnotations(messageBody); + for (Annotation annotation : mentionAnnotations) { + messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + return messageBody; } @@ -901,7 +909,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati } Quote quote = ((MediaMmsMessageRecord)current).getQuote(); //noinspection ConstantConditions - quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getText(), quote.isOriginalMissing(), quote.getAttachment()); + quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment()); quoteView.setVisibility(View.VISIBLE); quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; @@ -1405,6 +1413,24 @@ public class ConversationItem extends LinearLayout implements BindableConversati } } + private class MentionClickableSpan extends ClickableSpan { + private final RecipientId mentionedRecipientId; + + MentionClickableSpan(RecipientId mentionedRecipientId) { + this.mentionedRecipientId = mentionedRecipientId; + } + + @Override + public void onClick(@NonNull View widget) { + if (eventListener != null && !Recipient.resolved(mentionedRecipientId).isLocalNumber()) { + eventListener.onGroupMemberClicked(mentionedRecipientId, conversationRecipient.get().requireGroupId()); + } + } + + @Override + public void updateDrawState(@NonNull TextPaint ds) { } + } + private void handleMessageApproval() { final int title; final int message; 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 c0c80be42b..c93510c4dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -1,27 +1,58 @@ package org.thoughtcrime.securesms.conversation; -import androidx.annotation.NonNull; +import android.content.Context; +import android.text.SpannableString; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.WorkerThread; + +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MentionUtil; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.util.Conversions; import java.security.MessageDigest; +import java.util.Collections; +import java.util.List; /** * A view level model used to pass arbitrary message related information needed * for various presentations. */ public class ConversationMessage { - private final MessageRecord messageRecord; + @NonNull private final MessageRecord messageRecord; + @NonNull private final List mentions; + @Nullable private final SpannableString body; - public ConversationMessage(@NonNull MessageRecord messageRecord) { + private ConversationMessage(@NonNull MessageRecord messageRecord) { + this(messageRecord, null, null); + } + + private ConversationMessage(@NonNull MessageRecord messageRecord, + @Nullable CharSequence body, + @Nullable List mentions) + { this.messageRecord = messageRecord; + this.body = body != null ? SpannableString.valueOf(body) : null; + this.mentions = mentions != null ? mentions : Collections.emptyList(); + + if (!this.mentions.isEmpty() && this.body != null) { + MentionAnnotation.setMentionAnnotations(this.body, this.mentions); + } } public @NonNull MessageRecord getMessageRecord() { return messageRecord; } + public @NonNull List getMentions() { + return mentions; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -41,4 +72,74 @@ public class ConversationMessage { return Conversions.byteArrayToLong(bytes); } + + public @NonNull SpannableString getDisplayBody(Context context) { + if (mentions.isEmpty() || body == null) { + return messageRecord.getDisplayBody(context); + } + return body; + } + + /** + * Factory providing multiple ways of creating {@link ConversationMessage}s. + */ + 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) { + return new ConversationMessage(messageRecord); + } + + /** + * 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. + */ + @AnyThread + public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List mentions) { + if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) { + return new ConversationMessage(messageRecord, body, mentions); + } + return createWithResolvedData(messageRecord); + } + + /** + * 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. + * + * @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) { + if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions); + return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions()); + } + return createWithResolvedData(messageRecord); + } + + /** + * Creates a {@link ConversationMessage} wrapping the provided MessageRecord, and will query for potential mentions. If mentions + * are found, the body of the provided message will be updated and modified to match actual mentions. This will perform + * 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) { + if (messageRecord.isMms()) { + List mentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(messageRecord.getId()); + if (!mentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions); + return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions()); + } + } + return createWithResolvedData(messageRecord); + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java index 170c9d4435..6f18e6ac14 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java @@ -16,20 +16,24 @@ public class MentionViewHolder extends MappingViewHolder { private final AvatarImageView avatar; private final TextView name; + private final TextView username; + @Nullable private final MentionEventsListener mentionEventsListener; public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) { super(itemView); this.mentionEventsListener = mentionEventsListener; - avatar = findViewById(R.id.mention_recipient_avatar); - name = findViewById(R.id.mention_recipient_name); + avatar = findViewById(R.id.mention_recipient_avatar); + name = findViewById(R.id.mention_recipient_name); + username = findViewById(R.id.mention_recipient_username); } @Override public void bind(@NonNull MentionViewState model) { avatar.setRecipient(model.getRecipient()); name.setText(model.getName(context)); + username.setText(model.getUsername()); itemView.setOnClickListener(v -> { if (mentionEventsListener != null) { mentionEventsListener.onMentionClicked(model.getRecipient()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java index 52778749da..b7f2033bf8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.Util; import java.util.Objects; @@ -26,6 +27,10 @@ public final class MentionViewState implements MappingModel { return recipient; } + @NonNull String getUsername() { + return Util.emptyIfNull(recipient.getDisplayUsername()); + } + @Override public boolean areItemsTheSame(@NonNull MentionViewState newItem) { return recipient.getId().equals(newItem.recipient.getId()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java index 8ec090af3c..cef3bb84af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerFragment.java @@ -27,13 +27,15 @@ public class MentionsPickerFragment extends LoggingFragment { private RecyclerView list; private BottomSheetBehavior behavior; private MentionsPickerViewModel viewModel; + private int defaultPeekHeight; @Override public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false); - list = view.findViewById(R.id.mentions_picker_list); - behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet)); + list = view.findViewById(R.id.mentions_picker_list); + behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet)); + defaultPeekHeight = view.getContext().getResources().getDimensionPixelSize(R.dimen.mentions_picker_peek_height); return view; } @@ -72,13 +74,16 @@ public class MentionsPickerFragment extends LoggingFragment { if (mappingModels.isEmpty()) { updateBottomSheetBehavior(0); } + list.scrollToPosition(0); } private void updateBottomSheetBehavior(int count) { if (count > 0) { if (behavior.getPeekHeight() == 0) { - behavior.setPeekHeight(ViewUtil.dpToPx(240), true); + behavior.setPeekHeight(defaultPeekHeight, true); behavior.setState(BottomSheetBehavior.STATE_COLLAPSED); + } else { + list.scrollToPosition(0); } } else { behavior.setState(BottomSheetBehavior.STATE_COLLAPSED); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepository.java new file mode 100644 index 0000000000..ab2c23f1ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerRepository.java @@ -0,0 +1,51 @@ +package org.thoughtcrime.securesms.conversation.ui.mentions; + +import android.content.Context; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Collections; +import java.util.List; + +final class MentionsPickerRepository { + + private final RecipientDatabase recipientDatabase; + + MentionsPickerRepository(@NonNull Context context) { + recipientDatabase = DatabaseFactory.getRecipientDatabase(context); + } + + @WorkerThread + @NonNull List search(MentionQuery mentionQuery) { + if (TextUtils.isEmpty(mentionQuery.query)) { + return Collections.emptyList(); + } + + List recipientIds = Stream.of(mentionQuery.members) + .filterNot(m -> m.getMember().isLocalNumber()) + .map(m -> m.getMember().getId()) + .toList(); + + return recipientDatabase.queryRecipientsForMentions(mentionQuery.query, recipientIds); + } + + static class MentionQuery { + private final String query; + private final List members; + + MentionQuery(@NonNull String query, @NonNull List members) { + this.query = query; + this.members = members; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java index a92c9d10da..26df4c7876 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerViewModel.java @@ -1,7 +1,5 @@ package org.thoughtcrime.securesms.conversation.ui.mentions; -import android.text.TextUtils; - import androidx.annotation.NonNull; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; @@ -11,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider; import com.annimon.stream.Stream; +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.LiveGroup; @@ -20,7 +19,6 @@ import org.thoughtcrime.securesms.util.MappingModel; import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; -import java.util.Collections; import java.util.List; public class MentionsPickerViewModel extends ViewModel { @@ -28,17 +26,18 @@ public class MentionsPickerViewModel extends ViewModel { private final SingleLiveEvent selectedRecipient; private final LiveData>> mentionList; private final MutableLiveData group; - private final MutableLiveData liveQuery; + private final MutableLiveData liveQuery; - MentionsPickerViewModel() { + MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository) { group = new MutableLiveData<>(); liveQuery = new MutableLiveData<>(); selectedRecipient = new SingleLiveEvent<>(); - // TODO [cody] [mentions] simple query support implement for building UI/UX, to be replaced with better search before launch - LiveData> members = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers)); + LiveData> fullMembers = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers)); + LiveData query = Transformations.distinctUntilChanged(liveQuery); + LiveData mentionQuery = LiveDataUtil.combineLatest(query, fullMembers, MentionQuery::new); - mentionList = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(liveQuery), members, this::filterMembers); + mentionList = LiveDataUtil.mapAsync(mentionQuery, q -> Stream.of(mentionsPickerRepository.search(q)).>map(MentionViewState::new).toList()); } @NonNull LiveData>> getMentionList() { @@ -54,7 +53,7 @@ public class MentionsPickerViewModel extends ViewModel { } public void onQueryChange(@NonNull CharSequence query) { - liveQuery.setValue(query); + liveQuery.setValue(query.toString()); } public void onRecipientChange(@NonNull Recipient recipient) { @@ -65,22 +64,11 @@ public class MentionsPickerViewModel extends ViewModel { } } - private @NonNull List> filterMembers(@NonNull CharSequence query, @NonNull List members) { - if (TextUtils.isEmpty(query)) { - return Collections.emptyList(); - } - - return Stream.of(members) - .filter(m -> m.getMember().getDisplayName(ApplicationDependencies.getApplication()).toLowerCase().replaceAll("\\s", "").startsWith(query.toString())) - .>map(m -> new MentionViewState(m.getMember())) - .toList(); - } - public static final class Factory implements ViewModelProvider.Factory { @Override public @NonNull T create(@NonNull Class modelClass) { //noinspection ConstantConditions - return modelClass.cast(new MentionsPickerViewModel()); + return modelClass.cast(new MentionsPickerViewModel(new MentionsPickerRepository(ApplicationDependencies.getApplication()))); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java index 72db106a38..ab7a2a21a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseFactory.java @@ -63,6 +63,7 @@ public class DatabaseFactory { private final KeyValueDatabase keyValueDatabase; private final MegaphoneDatabase megaphoneDatabase; private final RemappedRecordsDatabase remappedRecordsDatabase; + private final MentionDatabase mentionDatabase; public static DatabaseFactory getInstance(Context context) { synchronized (lock) { @@ -165,6 +166,10 @@ public class DatabaseFactory { return getInstance(context).remappedRecordsDatabase; } + public static MentionDatabase getMentionDatabase(Context context) { + return getInstance(context).mentionDatabase; + } + public static SQLiteDatabase getBackupDatabase(Context context) { return getInstance(context).databaseHelper.getReadableDatabase(); } @@ -214,6 +219,7 @@ public class DatabaseFactory { this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper); this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper); this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper); + this.mentionDatabase = new MentionDatabase(context, databaseHelper); } public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java index f717e29743..bda4de1bdd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -4,6 +4,8 @@ import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.net.Uri; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import net.sqlcipher.database.SQLiteDatabase; @@ -73,14 +75,11 @@ public class DraftDatabase extends Database { db.delete(TABLE_NAME, null, null); } - public List getDrafts(long threadId) { - SQLiteDatabase db = databaseHelper.getReadableDatabase(); - List results = new LinkedList<>(); - Cursor cursor = null; - - try { - cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null); + public Drafts getDrafts(long threadId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Drafts results = new Drafts(); + try (Cursor cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null)) { while (cursor != null && cursor.moveToNext()) { String type = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_TYPE)); String value = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_VALUE)); @@ -89,9 +88,6 @@ public class DraftDatabase extends Database { } return results; - } finally { - if (cursor != null) - cursor.close(); } } @@ -102,6 +98,7 @@ public class DraftDatabase extends Database { public static final String AUDIO = "audio"; public static final String LOCATION = "location"; public static final String QUOTE = "quote"; + public static final String MENTION = "mention"; private final String type; private final String value; @@ -133,7 +130,7 @@ public class DraftDatabase extends Database { } public static class Drafts extends LinkedList { - private Draft getDraftOfType(String type) { + public @Nullable Draft getDraftOfType(String type) { for (Draft draft : this) { if (type.equals(draft.getType())) { return draft; @@ -142,7 +139,7 @@ public class DraftDatabase extends Database { return null; } - public String getSnippet(Context context) { + public @NonNull String getSnippet(Context context) { Draft textDraft = getDraftOfType(Draft.TEXT); if (textDraft != null) { return textDraft.getSnippet(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MentionDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MentionDatabase.java new file mode 100644 index 0000000000..3486dbb6ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MentionDatabase.java @@ -0,0 +1,112 @@ +package org.thoughtcrime.securesms.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.CursorUtil; +import org.thoughtcrime.securesms.util.SqlUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class MentionDatabase extends Database { + + private static final String TABLE_NAME = "mention"; + + private static final String ID = "_id"; + private static final String THREAD_ID = "thread_id"; + private static final String MESSAGE_ID = "message_id"; + private static final String RECIPIENT_ID = "recipient_id"; + private static final String RANGE_START = "range_start"; + private static final String RANGE_LENGTH = "range_length"; + + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + + THREAD_ID + " INTEGER, " + + MESSAGE_ID + " INTEGER, " + + RECIPIENT_ID + " INTEGER, " + + RANGE_START + " INTEGER, " + + RANGE_LENGTH + " INTEGER)"; + + public static final String[] CREATE_INDEXES = new String[] { + "CREATE INDEX IF NOT EXISTS mention_message_id_index ON " + TABLE_NAME + " (" + MESSAGE_ID + ");", + "CREATE INDEX IF NOT EXISTS mention_recipient_id_thread_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ", " + THREAD_ID + ");" + }; + + public MentionDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) { + super(context, databaseHelper); + } + + public void insert(long threadId, long messageId, @NonNull Collection mentions) { + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + + db.beginTransaction(); + try { + for (Mention mention : mentions) { + ContentValues values = new ContentValues(); + values.put(THREAD_ID, threadId); + values.put(MESSAGE_ID, messageId); + values.put(RECIPIENT_ID, mention.getRecipientId().toLong()); + values.put(RANGE_START, mention.getStart()); + values.put(RANGE_LENGTH, mention.getLength()); + db.insert(TABLE_NAME, null, values); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + public @NonNull List getMentionsForMessage(long messageId) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + List mentions = new LinkedList<>(); + + try (Cursor cursor = db.query(TABLE_NAME, null, MESSAGE_ID + " = ?", SqlUtil.buildArgs(messageId), null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + mentions.add(new Mention(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)), + CursorUtil.requireInt(cursor, RANGE_START), + CursorUtil.requireInt(cursor, RANGE_LENGTH))); + } + } + + return mentions; + } + + public @NonNull Map> getMentionsForMessages(@NonNull Collection messageIds) { + SQLiteDatabase db = databaseHelper.getReadableDatabase(); + Map> mentions = new HashMap<>(); + + String ids = TextUtils.join(",", messageIds); + + try (Cursor cursor = db.query(TABLE_NAME, null, MESSAGE_ID + " IN (" + ids + ")", null, null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + long messageId = CursorUtil.requireLong(cursor, MESSAGE_ID); + List messageMentions = mentions.get(messageId); + + if (messageMentions == null) { + messageMentions = new LinkedList<>(); + mentions.put(messageId, messageMentions); + } + + messageMentions.add(new Mention(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)), + CursorUtil.requireInt(cursor, RANGE_START), + CursorUtil.requireInt(cursor, RANGE_LENGTH))); + } + } + + return mentions; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java b/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java new file mode 100644 index 0000000000..1316c4b437 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MentionUtil.java @@ -0,0 +1,166 @@ +package org.thoughtcrime.securesms.database; + +import android.content.Context; +import android.text.SpannableStringBuilder; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +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.RecipientDatabase.MentionSetting; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.database.model.MessageRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.whispersystems.signalservice.api.util.UuidUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class MentionUtil { + + public static final char MENTION_STARTER = '@'; + static final String MENTION_PLACEHOLDER = "\uFFFC"; + + private MentionUtil() { } + + @WorkerThread + public static @NonNull CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord) { + return updateBodyWithDisplayNames(context, messageRecord, messageRecord.getDisplayBody(context)); + } + + @WorkerThread + public static @NonNull CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) { + if (messageRecord.isMms()) { + List mentions = DatabaseFactory.getMentionDatabase(context).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); + } + + @WorkerThread + public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithDisplayNames(@NonNull Context context, @NonNull CharSequence body, @NonNull List mentions) { + return update(body, mentions, m -> MENTION_STARTER + Recipient.resolved(m.getRecipientId()).getDisplayName(context)); + } + + public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithPlaceholders(@Nullable CharSequence body, @NonNull List mentions) { + return update(body, mentions, m -> MENTION_PLACEHOLDER); + } + + private static @NonNull UpdatedBodyAndMentions update(@Nullable CharSequence body, @NonNull List mentions, @NonNull Function replacementTextGenerator) { + if (body == null || mentions.isEmpty()) { + return new UpdatedBodyAndMentions(body, mentions); + } + + SpannableStringBuilder updatedBody = new SpannableStringBuilder(); + List updatedMentions = new ArrayList<>(); + + Collections.sort(mentions); + + int bodyIndex = 0; + + for (Mention mention : mentions) { + updatedBody.append(body.subSequence(bodyIndex, mention.getStart())); + CharSequence replaceWith = replacementTextGenerator.apply(mention); + Mention updatedMention = new Mention(mention.getRecipientId(), updatedBody.length(), replaceWith.length()); + + updatedBody.append(replaceWith); + updatedMentions.add(updatedMention); + + bodyIndex = mention.getStart() + mention.getLength(); + } + + if (bodyIndex < body.length()) { + updatedBody.append(body.subSequence(bodyIndex, body.length())); + } + + return new UpdatedBodyAndMentions(updatedBody.toString(), updatedMentions); + } + + public static @Nullable BodyRangeList mentionsToBodyRangeList(@Nullable List mentions) { + if (mentions == null || mentions.isEmpty()) { + return null; + } + + BodyRangeList.Builder builder = BodyRangeList.newBuilder(); + + for (Mention mention : mentions) { + String uuid = Recipient.resolved(mention.getRecipientId()).requireUuid().toString(); + builder.addRanges(BodyRangeList.BodyRange.newBuilder() + .setMentionUuid(uuid) + .setStart(mention.getStart()) + .setLength(mention.getLength())); + } + + 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(context, UuidUtil.parseOrThrow(mention.getMentionUuid()), null, false).getId(); + return new Mention(id, mention.getStart(), mention.getLength()); + }) + .toList(); + } catch (InvalidProtocolBufferException e) { + return Collections.emptyList(); + } + } else { + return Collections.emptyList(); + } + } + + public static @NonNull String getMentionSettingDisplayValue(@NonNull Context context, @NonNull MentionSetting mentionSetting) { + switch (mentionSetting) { + case GLOBAL: + return context.getString(SignalStore.notificationSettings().isMentionNotifiesMeEnabled() ? R.string.GroupMentionSettingDialog_default_notify_me + : R.string.GroupMentionSettingDialog_default_dont_notify_me); + 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); + } + + public static class UpdatedBodyAndMentions { + @Nullable private final CharSequence body; + @NonNull private final List mentions; + + public UpdatedBodyAndMentions(@Nullable CharSequence body, @NonNull List mentions) { + this.body = body; + this.mentions = mentions; + } + + public @Nullable CharSequence getBody() { + return body; + } + + public @NonNull List getMentions() { + return mentions; + } + + @Nullable String getBodyAsString() { + return body != null ? body.toString() : null; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java index e83d571e4b..b7282b0b7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java @@ -47,10 +47,12 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.documents.NetworkFailureList; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord; import org.thoughtcrime.securesms.database.model.Quote; import org.thoughtcrime.securesms.database.model.ReactionRecord; +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.TrimThreadJob; import org.thoughtcrime.securesms.linkpreview.LinkPreview; @@ -68,6 +70,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo; import org.thoughtcrime.securesms.revealable.ViewOnceUtil; +import org.thoughtcrime.securesms.util.CursorUtil; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; @@ -109,9 +112,11 @@ public class MmsDatabase extends MessagingDatabase { static final String QUOTE_BODY = "quote_body"; static final String QUOTE_ATTACHMENT = "quote_attachment"; static final String QUOTE_MISSING = "quote_missing"; + static final String QUOTE_MENTIONS = "quote_mentions"; static final String SHARED_CONTACTS = "shared_contacts"; static final String LINK_PREVIEWS = "previews"; + static final String MENTIONS_SELF = "mentions_self"; public static final String VIEW_ONCE = "reveal_duration"; @@ -163,6 +168,7 @@ public class MmsDatabase extends MessagingDatabase { QUOTE_BODY + " TEXT, " + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " + QUOTE_MISSING + " INTEGER DEFAULT 0, " + + QUOTE_MENTIONS + " BLOB DEFAULT NULL," + SHARED_CONTACTS + " TEXT, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " + LINK_PREVIEWS + " TEXT, " + @@ -170,7 +176,8 @@ public class MmsDatabase extends MessagingDatabase { REACTIONS + " BLOB DEFAULT NULL, " + REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " + - REMOTE_DELETED + " INTEGER DEFAULT 0);"; + REMOTE_DELETED + " INTEGER DEFAULT 0, " + + MENTIONS_SELF + " INTEGER DEFAULT 0);"; public static final String[] CREATE_INDEXS = { "CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", @@ -193,9 +200,9 @@ public class MmsDatabase extends MessagingDatabase { MESSAGE_SIZE, STATUS, TRANSACTION_ID, BODY, PART_COUNT, RECIPIENT_ID, ADDRESS_DEVICE_ID, DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID, - EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, + EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, QUOTE_MENTIONS, SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN, - REMOTE_DELETED, + REMOTE_DELETED, MENTIONS_SELF, "json_group_array(json_object(" + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + @@ -805,6 +812,7 @@ public class MmsDatabase extends MessagingDatabase { throws MmsException, NoSuchMessageException { AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); + MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context); Cursor cursor = null; try { @@ -812,6 +820,7 @@ public class MmsDatabase extends MessagingDatabase { if (cursor != null && cursor.moveToNext()) { List associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId); + List mentions = mentionDatabase.getMentionsForMessage(messageId); long outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)); String body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)); @@ -830,6 +839,7 @@ public class MmsDatabase extends MessagingDatabase { String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)); 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(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); @@ -846,7 +856,7 @@ public class MmsDatabase extends MessagingDatabase { QuoteModel quote = null; if (quoteId > 0 && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || !quoteAttachments.isEmpty())) { - quote = new QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteAttachments); + quote = new QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteAttachments, quoteMentions); } if (!TextUtils.isEmpty(mismatchDocument)) { @@ -866,12 +876,12 @@ public class MmsDatabase extends MessagingDatabase { } if (body != null && (Types.isGroupQuit(outboxType) || Types.isGroupUpdate(outboxType))) { - return new OutgoingGroupUpdateMessage(recipient, new MessageGroupContext(body, Types.isGroupV2(outboxType)), attachments, timestamp, 0, false, quote, contacts, previews); + return new OutgoingGroupUpdateMessage(recipient, new MessageGroupContext(body, Types.isGroupV2(outboxType)), attachments, timestamp, 0, false, quote, contacts, previews, mentions); } else if (Types.isExpirationTimerUpdate(outboxType)) { return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn); } - OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, networkFailures, mismatches); + OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions, networkFailures, mismatches); if (Types.isSecureType(outboxType)) { return new OutgoingSecureMediaMessage(message); @@ -1000,10 +1010,15 @@ public class MmsDatabase extends MessagingDatabase { if (retrieved.getQuote() != null) { contentValues.put(QUOTE_ID, retrieved.getQuote().getId()); - contentValues.put(QUOTE_BODY, retrieved.getQuote().getText()); + contentValues.put(QUOTE_BODY, retrieved.getQuote().getText().toString()); contentValues.put(QUOTE_AUTHOR, retrieved.getQuote().getAuthor().serialize()); contentValues.put(QUOTE_MISSING, retrieved.getQuote().isOriginalMissing() ? 1 : 0); + BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(retrieved.getQuote().getMentions()); + if (mentionsList != null) { + contentValues.put(QUOTE_MENTIONS, mentionsList.toByteArray()); + } + quoteAttachments = retrieved.getQuote().getAttachments(); } @@ -1012,7 +1027,7 @@ public class MmsDatabase extends MessagingDatabase { return Optional.absent(); } - long messageId = insertMediaMessage(retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), retrieved.getLinkPreviews(), contentValues, null); + long messageId = insertMediaMessage(threadId, retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), retrieved.getLinkPreviews(), retrieved.getMentions(), contentValues, null); if (!Types.isExpirationTimerUpdate(mailbox)) { DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); @@ -1158,15 +1173,23 @@ public class MmsDatabase extends MessagingDatabase { List quoteAttachments = new LinkedList<>(); if (message.getOutgoingQuote() != null) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getOutgoingQuote().getText(), message.getOutgoingQuote().getMentions()); + contentValues.put(QUOTE_ID, message.getOutgoingQuote().getId()); contentValues.put(QUOTE_AUTHOR, message.getOutgoingQuote().getAuthor().serialize()); - contentValues.put(QUOTE_BODY, message.getOutgoingQuote().getText()); + contentValues.put(QUOTE_BODY, updated.getBodyAsString()); contentValues.put(QUOTE_MISSING, message.getOutgoingQuote().isOriginalMissing() ? 1 : 0); + BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(updated.getMentions()); + if (mentionsList != null) { + contentValues.put(QUOTE_MENTIONS, mentionsList.toByteArray()); + } + quoteAttachments.addAll(message.getOutgoingQuote().getAttachments()); } - long messageId = insertMediaMessage(message.getBody(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), contentValues, insertListener); + MentionUtil.UpdatedBodyAndMentions updatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getBody(), message.getMentions()); + long messageId = insertMediaMessage(threadId, updatedBodyAndMentions.getBodyAsString(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), updatedBodyAndMentions.getMentions(), contentValues, insertListener); if (message.getRecipient().isGroup()) { OutgoingGroupUpdateMessage outgoingGroupUpdateMessage = (message instanceof OutgoingGroupUpdateMessage) ? (OutgoingGroupUpdateMessage) message : null; @@ -1197,17 +1220,22 @@ public class MmsDatabase extends MessagingDatabase { return messageId; } - private long insertMediaMessage(@Nullable String body, + private long insertMediaMessage(long threadId, + @Nullable String body, @NonNull List attachments, @NonNull List quoteAttachments, @NonNull List sharedContacts, @NonNull List linkPreviews, + @NonNull List mentions, @NonNull ContentValues contentValues, @Nullable SmsDatabase.InsertListener insertListener) throws MmsException { - SQLiteDatabase db = databaseHelper.getWritableDatabase(); - AttachmentDatabase partsDatabase = DatabaseFactory.getAttachmentDatabase(context); + SQLiteDatabase db = databaseHelper.getWritableDatabase(); + AttachmentDatabase partsDatabase = DatabaseFactory.getAttachmentDatabase(context); + MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context); + + boolean mentionsSelf = Stream.of(mentions).filter(m -> Recipient.resolved(m.getRecipientId()).isLocalNumber()).findFirst().isPresent(); List allAttachments = new LinkedList<>(); List contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList(); @@ -1219,11 +1247,14 @@ public class MmsDatabase extends MessagingDatabase { contentValues.put(BODY, body); contentValues.put(PART_COUNT, allAttachments.size()); + contentValues.put(MENTIONS_SELF, mentionsSelf ? 1 : 0); db.beginTransaction(); try { long messageId = db.insert(TABLE_NAME, null, contentValues); + mentionDatabase.insert(threadId, messageId, mentions); + Map insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments); String serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts); String serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews); @@ -1468,6 +1499,12 @@ public class MmsDatabase extends MessagingDatabase { } } + private @NonNull List parseQuoteMentions(Cursor cursor) { + byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_MENTIONS)); + + return MentionUtil.bodyRangeListToMentions(context, raw); + } + public void beginTransaction() { databaseHelper.getWritableDatabase().beginTransaction(); } @@ -1542,6 +1579,16 @@ public class MmsDatabase extends MessagingDatabase { 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(); + + if (quoteText != null && !quoteMentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions); + + quoteText = updated.getBody(); + quoteMentions = updated.getMentions(); + } + return new MediaMmsMessageRecord(id, message.getRecipient(), message.getRecipient(), @@ -1564,14 +1611,16 @@ public class MmsDatabase extends MessagingDatabase { message.getOutgoingQuote() != null ? new Quote(message.getOutgoingQuote().getId(), message.getOutgoingQuote().getAuthor(), - message.getOutgoingQuote().getText(), + quoteText, message.getOutgoingQuote().isOriginalMissing(), - new SlideDeck(context, message.getOutgoingQuote().getAttachments())) : + new SlideDeck(context, message.getOutgoingQuote().getAttachments()), + quoteMentions) : null, message.getSharedContacts(), message.getLinkPreviews(), false, Collections.emptyList(), + false, false); } } @@ -1665,6 +1714,7 @@ public class MmsDatabase extends MessagingDatabase { boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1; boolean remoteDelete = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REMOTE_DELETED)) == 1; List reactions = parseReactions(cursor); + boolean mentionsSelf = CursorUtil.requireBoolean(cursor, MENTIONS_SELF); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -1686,7 +1736,7 @@ public class MmsDatabase extends MessagingDatabase { threadId, body, slideDeck, partCount, box, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions, - remoteDelete); + remoteDelete, mentionsSelf); } private List getMismatchedIdentities(String document) { @@ -1724,14 +1774,22 @@ public class MmsDatabase extends MessagingDatabase { private @Nullable Quote getQuote(@NonNull Cursor cursor) { long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_ID)); long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_AUTHOR)); - String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_BODY)); + CharSequence quoteText = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_BODY)); boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_MISSING)) == 1; + List quoteMentions = parseQuoteMentions(cursor); List attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor); List quoteAttachments = Stream.of(attachments).filter(Attachment::isQuote).toList(); SlideDeck quoteDeck = new SlideDeck(context, quoteAttachments); if (quoteId > 0 && quoteAuthor > 0) { - return new Quote(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteDeck); + if (quoteText != null && !quoteMentions.isEmpty()) { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions); + + quoteText = updated.getBody(); + quoteMentions = updated.getMentions(); + } + + return new Quote(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteDeck, quoteMentions); } else { return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java index c4b98eacdd..3266c419e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsDatabase.java @@ -82,6 +82,7 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_ATTACHMENT, + MmsDatabase.QUOTE_MENTIONS, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, MmsDatabase.VIEW_ONCE, @@ -89,7 +90,8 @@ public class MmsSmsDatabase extends Database { MmsSmsColumns.REACTIONS, MmsSmsColumns.REACTIONS_UNREAD, MmsSmsColumns.REACTIONS_LAST_SEEN, - MmsSmsColumns.REMOTE_DELETED}; + MmsSmsColumns.REMOTE_DELETED, + MmsDatabase.MENTIONS_SELF}; public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { super(context, databaseHelper); @@ -408,6 +410,7 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_ATTACHMENT, + MmsDatabase.QUOTE_MENTIONS, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, MmsDatabase.VIEW_ONCE, @@ -415,7 +418,8 @@ public class MmsSmsDatabase extends Database { MmsSmsColumns.REACTIONS_UNREAD, MmsSmsColumns.REACTIONS_LAST_SEEN, MmsSmsColumns.DATE_SERVER, - MmsSmsColumns.REMOTE_DELETED }; + MmsSmsColumns.REMOTE_DELETED, + MmsDatabase.MENTIONS_SELF }; String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, @@ -440,6 +444,7 @@ public class MmsSmsDatabase extends Database { MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_ATTACHMENT, + MmsDatabase.QUOTE_MENTIONS, MmsDatabase.SHARED_CONTACTS, MmsDatabase.LINK_PREVIEWS, MmsDatabase.VIEW_ONCE, @@ -447,7 +452,8 @@ public class MmsSmsDatabase extends Database { MmsSmsColumns.REACTIONS_UNREAD, MmsSmsColumns.REACTIONS_LAST_SEEN, MmsSmsColumns.DATE_SERVER, - MmsSmsColumns.REMOTE_DELETED }; + MmsSmsColumns.REMOTE_DELETED, + MmsDatabase.MENTIONS_SELF }; SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); @@ -493,6 +499,7 @@ public class MmsSmsDatabase extends Database { mmsColumnsPresent.add(MmsDatabase.QUOTE_BODY); mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING); mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT); + mmsColumnsPresent.add(MmsDatabase.QUOTE_MENTIONS); mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS); mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE); @@ -500,6 +507,7 @@ public class MmsSmsDatabase extends Database { mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD); mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN); mmsColumnsPresent.add(MmsDatabase.REMOTE_DELETED); + mmsColumnsPresent.add(MmsDatabase.MENTIONS_SELF); Set smsColumnsPresent = new HashSet<>(); smsColumnsPresent.add(MmsSmsColumns.ID); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java index e3f62fa31f..200e0e9f59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.java @@ -119,13 +119,13 @@ public class RecipientDatabase extends Database { private static final String PROFILE_GIVEN_NAME = "signal_profile_name"; private static final String PROFILE_FAMILY_NAME = "profile_family_name"; private static final String PROFILE_JOINED_NAME = "profile_joined_name"; + private static final String MENTION_SETTING = "mention_setting"; public static final String SEARCH_PROFILE_NAME = "search_signal_profile"; private static final String SORT_NAME = "sort_name"; private static final String IDENTITY_STATUS = "identity_status"; private static final String IDENTITY_KEY = "identity_key"; - private static final String[] RECIPIENT_PROJECTION = new String[] { UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE, BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED, @@ -136,7 +136,8 @@ public class RecipientDatabase extends Database { UNIDENTIFIED_ACCESS_MODE, FORCE_SMS_SELECTION, UUID_CAPABILITY, GROUPS_V2_CAPABILITY, - STORAGE_SERVICE_ID, DIRTY + STORAGE_SERVICE_ID, DIRTY, + MENTION_SETTING }; private static final String[] ID_PROJECTION = new String[]{ID}; @@ -146,6 +147,8 @@ public class RecipientDatabase extends Database { .map(columnName -> TABLE_NAME + "." + columnName) .toList().toArray(new String[0]); + private static final String[] MENTION_SEARCH_PROJECTION = new String[]{ID, removeWhitespace("COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ", " + nullIfEmpty(PHONE) + ")") + " AS " + SORT_NAME}; + private static final String[] RECIPIENT_FULL_PROJECTION = ArrayUtils.concat( new String[] { TABLE_NAME + "." + ID }, TYPED_RECIPIENT_PROJECTION, @@ -275,6 +278,24 @@ public class RecipientDatabase extends Database { } } + public enum MentionSetting { + GLOBAL(0), ALWAYS_NOTIFY(1), DO_NOT_NOTIFY(2); + + private final int id; + + MentionSetting(int id) { + this.id = id; + } + + int getId() { + return id; + } + + public static MentionSetting fromId(int id) { + return values()[id]; + } + } + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + UUID + " TEXT UNIQUE DEFAULT NULL, " + @@ -314,7 +335,8 @@ public class RecipientDatabase extends Database { UUID_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " + GROUPS_V2_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " + STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " + - DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ");"; + DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ", " + + MENTION_SETTING + " INTEGER DEFAULT " + MentionSetting.GLOBAL.getId() + ");"; private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID + " FROM " + TABLE_NAME + @@ -1078,6 +1100,7 @@ public class RecipientDatabase extends Database { int uuidCapabilityValue = CursorUtil.requireInt(cursor, UUID_CAPABILITY); int groupsV2CapabilityValue = CursorUtil.requireInt(cursor, GROUPS_V2_CAPABILITY); String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID); + int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING); Optional identityKeyRaw = CursorUtil.getString(cursor, IDENTITY_KEY); Optional identityStatusRaw = CursorUtil.getInt(cursor, IDENTITY_STATUS); @@ -1145,7 +1168,7 @@ public class RecipientDatabase extends Database { Recipient.Capability.deserialize(uuidCapabilityValue), Recipient.Capability.deserialize(groupsV2CapabilityValue), InsightsBannerTier.fromId(insightsBannerTier), - storageKey, identityKey, identityStatus); + storageKey, identityKey, identityStatus, MentionSetting.fromId(mentionSettingId)); } public BulkOperationsHandle beginBulkSystemContactUpdate() { @@ -1299,6 +1322,14 @@ public class RecipientDatabase extends Database { } } + public void setMentionSetting(@NonNull RecipientId id, @NonNull MentionSetting mentionSetting) { + ContentValues values = new ContentValues(); + values.put(MENTION_SETTING, mentionSetting.getId()); + if (update(id, values)) { + Recipient.live(id).refresh(); + } + } + /** * Updates the profile key. *

@@ -1902,6 +1933,29 @@ public class RecipientDatabase extends Database { return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null); } + public @NonNull List queryRecipientsForMentions(@NonNull String query, @NonNull List recipientIds) { + if (TextUtils.isEmpty(query) || recipientIds.isEmpty()) { + return Collections.emptyList(); + } + + query = buildCaseInsensitiveGlobPattern(query); + + String ids = TextUtils.join(",", Stream.of(recipientIds).map(RecipientId::serialize).toList()); + + String selection = BLOCKED + " = 0 AND " + + ID + " IN (" + ids + ") AND " + + SORT_NAME + " GLOB ?"; + + List recipients = new ArrayList<>(); + try (RecipientDatabase.RecipientReader reader = new RecipientReader(databaseHelper.getReadableDatabase().query(TABLE_NAME, MENTION_SEARCH_PROJECTION, selection, SqlUtil.buildArgs(query), null, null, SORT_NAME))) { + Recipient recipient; + while ((recipient = reader.getNext()) != null) { + recipients.add(recipient); + } + } + return recipients; + } + /** * Builds a case-insensitive GLOB pattern for fuzzy text queries. Works with all unicode * characters. @@ -2384,6 +2438,10 @@ public class RecipientDatabase extends Database { return "NULLIF(" + column + ", '')"; } + private static @NonNull String removeWhitespace(@NonNull String column) { + return "REPLACE(" + column + ", ' ', '')"; + } + public interface ColorUpdater { MaterialColor update(@NonNull String name, @Nullable String color); } @@ -2427,6 +2485,7 @@ public class RecipientDatabase extends Database { private final byte[] storageId; private final byte[] identityKey; private final IdentityDatabase.VerifiedStatus identityStatus; + private final MentionSetting mentionSetting; RecipientSettings(@NonNull RecipientId id, @Nullable UUID uuid, @@ -2465,7 +2524,8 @@ public class RecipientDatabase extends Database { @NonNull InsightsBannerTier insightsBannerTier, @Nullable byte[] storageId, @Nullable byte[] identityKey, - @NonNull IdentityDatabase.VerifiedStatus identityStatus) + @NonNull IdentityDatabase.VerifiedStatus identityStatus, + @NonNull MentionSetting mentionSetting) { this.id = id; this.uuid = uuid; @@ -2505,6 +2565,7 @@ public class RecipientDatabase extends Database { this.storageId = storageId; this.identityKey = identityKey; this.identityStatus = identityStatus; + this.mentionSetting = mentionSetting; } public RecipientId getId() { @@ -2661,6 +2722,10 @@ public class RecipientDatabase extends Database { public @NonNull IdentityDatabase.VerifiedStatus getIdentityStatus() { return identityStatus; } + + public @NonNull MentionSetting getMentionSetting() { + return mentionSetting; + } } public static class RecipientReader implements Closeable { 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 55074bb942..7611e76be8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadBodyUtil.java @@ -66,7 +66,7 @@ public final class ThreadBodyUtil { return context.getString(R.string.ThreadRecord_media_message); } else { Log.w(TAG, "Got a media message with a body of a type we were not able to process. [contains media slide]:" + record.containsMediaSlide()); - return record.getBody(); + return getBody(context, record); } } @@ -75,10 +75,10 @@ public final class ThreadBodyUtil { } private static @NonNull String getBodyOrDefault(@NonNull Context context, @NonNull MessageRecord record, @StringRes int defaultStringRes) { - if (TextUtils.isEmpty(record.getBody())) { - return context.getString(defaultStringRes); - } else { - return record.getBody(); - } + return TextUtils.isEmpty(record.getBody()) ? context.getString(defaultStringRes) : getBody(context, record); + } + + private static @NonNull String getBody(@NonNull Context context, @NonNull MessageRecord record) { + return MentionUtil.updateBodyWithDisplayNames(context, record, record.getBody()).toString(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 75e909bf6d..069947d15e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -22,6 +22,7 @@ import net.sqlcipher.database.SQLiteDatabaseHook; import net.sqlcipher.database.SQLiteOpenHelper; import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy; +import org.thoughtcrime.securesms.database.MentionDatabase; import org.thoughtcrime.securesms.database.RemappedRecordsDatabase; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.ProfileName; @@ -139,8 +140,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int QUOTE_CLEANUP = 65; private static final int BORDERLESS = 66; private static final int REMAPPED_RECORDS = 67; + private static final int MENTIONS = 68; - private static final int DATABASE_VERSION = 67; + private static final int DATABASE_VERSION = 68; private static final String DATABASE_NAME = "signal.db"; private final Context context; @@ -184,6 +186,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { db.execSQL(StorageKeyDatabase.CREATE_TABLE); db.execSQL(KeyValueDatabase.CREATE_TABLE); db.execSQL(MegaphoneDatabase.CREATE_TABLE); + db.execSQL(MentionDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, JobDatabase.CREATE_TABLE); executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE); @@ -198,6 +201,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES); executeStatements(db, StickerDatabase.CREATE_INDEXES); executeStatements(db, StorageKeyDatabase.CREATE_INDEXES); + executeStatements(db, MentionDatabase.CREATE_INDEXES); if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) { ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context); @@ -970,6 +974,23 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { "new_id INTEGER)"); } + if (oldVersion < MENTIONS) { + db.execSQL("CREATE TABLE mention (_id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "thread_id INTEGER, " + + "message_id INTEGER, " + + "recipient_id INTEGER, " + + "range_start INTEGER, " + + "range_length INTEGER)"); + + db.execSQL("CREATE INDEX IF NOT EXISTS mention_message_id_index ON mention (message_id)"); + db.execSQL("CREATE INDEX IF NOT EXISTS mention_recipient_id_thread_id_index ON mention (recipient_id, thread_id);"); + + db.execSQL("ALTER TABLE mms ADD COLUMN quote_mentions BLOB DEFAULT NULL"); + db.execSQL("ALTER TABLE mms ADD COLUMN mentions_self INTEGER DEFAULT 0"); + + db.execSQL("ALTER TABLE recipient ADD COLUMN mention_setting INTEGER DEFAULT 0"); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); 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 260db0b4c5..069f95f8d9 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 @@ -45,6 +45,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { private final static String TAG = MediaMmsMessageRecord.class.getSimpleName(); private final int partCount; + private final boolean mentionsSelf; public MediaMmsMessageRecord(long id, Recipient conversationRecipient, @@ -71,19 +72,26 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { @NonNull List linkPreviews, boolean unidentified, @NonNull List reactions, - boolean remoteDelete) + boolean remoteDelete, + boolean mentionsSelf) { super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck, readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete); - this.partCount = partCount; + this.partCount = partCount; + this.mentionsSelf = mentionsSelf; } public int getPartCount() { return partCount; } + @Override + public boolean hasSelfMention() { + return mentionsSelf; + } + @Override public boolean isMmsNotification() { return false; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/Mention.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/Mention.java new file mode 100644 index 0000000000..018e5ad345 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/Mention.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.database.model; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.Objects; + +public class Mention implements Comparable, Parcelable { + private final RecipientId recipientId; + private final int start; + private final int length; + + public Mention(@NonNull RecipientId recipientId, int start, int length) { + this.recipientId = recipientId; + this.start = start; + this.length = length; + } + + protected Mention(Parcel in) { + recipientId = in.readParcelable(RecipientId.class.getClassLoader()); + start = in.readInt(); + length = in.readInt(); + } + + public @NonNull RecipientId getRecipientId() { + return recipientId; + } + + public int getStart() { + return start; + } + + public int getLength() { + return length; + } + + @Override + public int compareTo(Mention other) { + return Integer.compare(start, other.start); + } + + @Override + public int hashCode() { + return Objects.hash(recipientId, start, length); + } + + @Override + public boolean equals(@Nullable Object object) { + if (this == object) { + return true; + } + + if (object == null || getClass() != object.getClass()) { + return false; + } + + Mention that = (Mention) object; + return recipientId.equals(that.recipientId) && start == that.start && length == that.length; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(recipientId, flags); + dest.writeInt(start); + dest.writeInt(length); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = new Creator() { + @Override + public Mention createFromParcel(Parcel in) { + return new Mention(in); + } + + @Override + public Mention[] newArray(int size) { + return new Mention[size]; + } + }; +} 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 575d35e07f..50bbedf36f 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 @@ -374,4 +374,8 @@ public abstract class MessageRecord extends DisplayRecord { public @NonNull List getReactions() { return reactions; } + + public boolean hasSelfMention() { + return false; + } } 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 ada38f09eb..d0eb177526 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 @@ -1,26 +1,42 @@ package org.thoughtcrime.securesms.database.model; +import android.text.SpannableString; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.RecipientId; +import java.util.List; + public class Quote { - private final long id; - private final RecipientId author; - private final String text; - private final boolean missing; - private final SlideDeck attachment; + private final long id; + private final RecipientId author; + private final CharSequence text; + private final boolean missing; + private final SlideDeck attachment; + private final List mentions; - public Quote(long id, @NonNull RecipientId author, @Nullable String text, boolean missing, @NonNull SlideDeck attachment) { - this.id = id; - this.author = author; - this.text = text; - this.missing = missing; - this.attachment = attachment; + public Quote(long id, + @NonNull RecipientId author, + @Nullable CharSequence text, + boolean missing, + @NonNull SlideDeck attachment, + @NonNull List mentions) + { + this.id = id; + this.author = author; + this.missing = missing; + this.attachment = attachment; + this.mentions = mentions; + + SpannableString spannable = new SpannableString(text); + MentionAnnotation.setMentionAnnotations(spannable, mentions); + + this.text = spannable; } public long getId() { @@ -31,7 +47,7 @@ public class Quote { return author; } - public @Nullable String getText() { + public @Nullable CharSequence getDisplayText() { return text; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java index 0fb01d8153..882f7c470d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV1.java @@ -149,7 +149,7 @@ final class GroupManagerV1 { avatarAttachment = new UriAttachment(avatarUri, MediaUtil.IMAGE_PNG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, avatar.length, null, false, false, false, null, null, null, null, null); } - OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList()); + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); long threadId = MessageSender.send(context, outgoingMessage, -1, false, null); return new GroupActionResult(groupRecipient, threadId, newMemberCount, Collections.emptyList()); @@ -241,6 +241,7 @@ final class GroupManagerV1 { false, null, Collections.emptyList(), + Collections.emptyList(), Collections.emptyList())); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java index 8cc558d621..65502203e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2.java @@ -519,6 +519,7 @@ final class GroupManagerV2 { false, null, Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java index 39e66af327..5cdbdebd74 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupV1MessageProcessor.java @@ -240,7 +240,7 @@ public final class GroupV1MessageProcessor { MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupId.v1orThrow(group.getGroupId())); Recipient recipient = Recipient.resolved(recipientId); - OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(recipient, storage, null, content.getTimestamp(), 0, false, null, Collections.emptyList(), Collections.emptyList()); + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(recipient, storage, null, content.getTimestamp(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java index 923a87e44c..8be92a6ed0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupFragment.java @@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; import org.thoughtcrime.securesms.util.views.LearnMoreTextView; @@ -99,6 +100,8 @@ public class ManageGroupFragment extends LoggingFragment { private TextView muteNotificationsUntilLabel; private TextView customNotificationsButton; private View customNotificationsRow; + private View mentionsRow; + private TextView mentionsValue; private View toggleAllMembers; private final Recipient.FallbackPhotoProvider fallbackPhotoProvider = new Recipient.FallbackPhotoProvider() { @@ -156,6 +159,8 @@ public class ManageGroupFragment extends LoggingFragment { muteNotificationsRow = view.findViewById(R.id.group_mute_notifications_row); customNotificationsButton = view.findViewById(R.id.group_custom_notifications_button); customNotificationsRow = view.findViewById(R.id.group_custom_notifications_row); + mentionsRow = view.findViewById(R.id.group_mentions_row); + mentionsValue = view.findViewById(R.id.group_mentions_value); toggleAllMembers = view.findViewById(R.id.toggle_all_members); groupV1Indicator.setOnLinkClickListener(v -> GroupsLearnMoreBottomSheetDialogFragment.show(requireFragmentManager())); @@ -317,7 +322,6 @@ public class ManageGroupFragment extends LoggingFragment { customNotificationsRow.setVisibility(View.VISIBLE); - //noinspection CodeBlock2Expr if (NotificationChannels.supported()) { viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { customNotificationsButton.setText(hasCustomNotifications ? R.string.ManageGroupActivity_on @@ -325,6 +329,10 @@ public class ManageGroupFragment extends LoggingFragment { }); } + mentionsRow.setVisibility(FeatureFlags.mentions() && groupId.isV2() ? View.VISIBLE : View.GONE); + mentionsRow.setOnClickListener(v -> viewModel.handleMentionNotificationSelection()); + viewModel.getMentionSetting().observe(getViewLifecycleOwner(), value -> mentionsValue.setText(value)); + viewModel.getSnackbarEvents().observe(getViewLifecycleOwner(), this::handleSnackbarEvent); viewModel.getInvitedDialogEvents().observe(getViewLifecycleOwner(), this::handleInvitedDialogEvent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java index 8f6f74ffcc..89fc951bfe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupRepository.java @@ -12,6 +12,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup; import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.groups.GroupAccessControl; import org.thoughtcrime.securesms.groups.GroupChangeException; @@ -152,6 +153,13 @@ final class ManageGroupRepository { }); } + void setMentionSetting(RecipientDatabase.MentionSetting mentionSetting) { + SignalExecutors.BOUNDED.execute(() -> { + RecipientId recipientId = Recipient.externalGroup(context, groupId).getId(); + DatabaseFactory.getRecipientDatabase(context).setMentionSetting(recipientId, mentionSetting); + }); + } + static final class GroupStateResult { private final long threadId; diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java index 6e805a2353..da1ce74bc3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/ManageGroupViewModel.java @@ -22,6 +22,8 @@ import org.thoughtcrime.securesms.ExpirationDialog; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.database.MediaDatabase; +import org.thoughtcrime.securesms.database.MentionUtil; +import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.loaders.MediaLoader; import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; import org.thoughtcrime.securesms.groups.GroupAccessControl; @@ -31,6 +33,7 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason; import org.thoughtcrime.securesms.groups.ui.GroupErrors; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity; +import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupMentionSettingDialog; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -75,6 +78,7 @@ public class ManageGroupViewModel extends ViewModel { private final LiveData canLeaveGroup; private final LiveData canBlockGroup; private final LiveData showLegacyIndicator; + private final LiveData mentionSetting; private ManageGroupViewModel(@NonNull Context context, @NonNull ManageGroupRepository manageGroupRepository) { this.context = context; @@ -114,6 +118,8 @@ public class ManageGroupViewModel extends ViewModel { recipient -> recipient.getNotificationChannel() != null || !NotificationChannels.supported()); this.canLeaveGroup = liveGroup.isActive(); this.canBlockGroup = Transformations.map(this.groupRecipient, recipient -> !recipient.isBlocked()); + this.mentionSetting = Transformations.distinctUntilChanged(Transformations.map(this.groupRecipient, + recipient -> MentionUtil.getMentionSettingDisplayValue(context, recipient.getMentionSetting()))); } @WorkerThread @@ -207,6 +213,10 @@ public class ManageGroupViewModel extends ViewModel { return canLeaveGroup; } + LiveData getMentionSetting() { + return mentionSetting; + } + void handleExpirationSelection() { manageGroupRepository.getRecipient(groupRecipient -> ExpirationDialog.show(context, @@ -250,6 +260,10 @@ public class ManageGroupViewModel extends ViewModel { memberListCollapseState.setValue(CollapseState.OPEN); } + void handleMentionNotificationSelection() { + manageGroupRepository.getRecipient(r -> GroupMentionSettingDialog.show(context, r.getMentionSetting(), mentionSetting -> manageGroupRepository.setMentionSetting(mentionSetting))); + } + private void onBlockAndLeaveConfirmed() { SimpleProgressDialog.DismissibleDialog dismissibleDialog = SimpleProgressDialog.showDelayed(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java new file mode 100644 index 0000000000..dae3c0fb94 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/managegroup/dialogs/GroupMentionSettingDialog.java @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.groups.ui.managegroup.dialogs; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.CheckedTextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.core.util.Consumer; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; +import org.thoughtcrime.securesms.keyvalue.SignalStore; + +public final class GroupMentionSettingDialog { + + public static void show(@NonNull Context context, @NonNull MentionSetting mentionSetting, @Nullable Consumer callback) { + SelectionCallback selectionCallback = new SelectionCallback(mentionSetting, callback); + + new AlertDialog.Builder(context) + .setTitle(R.string.GroupMentionSettingDialog_notify_me_for_mentions) + .setView(getView(context, mentionSetting, selectionCallback)) + .setPositiveButton(android.R.string.ok, selectionCallback) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + @SuppressLint("InflateParams") + private static View getView(@NonNull Context context, @NonNull MentionSetting mentionSetting, @NonNull SelectionCallback selectionCallback) { + View root = LayoutInflater.from(context).inflate(R.layout.group_mention_setting_dialog, null, false); + CheckedTextView defaultOption = root.findViewById(R.id.group_mention_setting_default); + CheckedTextView alwaysNotify = root.findViewById(R.id.group_mention_setting_always_notify); + CheckedTextView dontNotify = root.findViewById(R.id.group_mention_setting_dont_notify); + + defaultOption.setText(SignalStore.notificationSettings().isMentionNotifiesMeEnabled() ? R.string.GroupMentionSettingDialog_default_notify_me + : R.string.GroupMentionSettingDialog_default_dont_notify_me); + + View.OnClickListener listener = (v) -> { + defaultOption.setChecked(defaultOption == v); + alwaysNotify.setChecked(alwaysNotify == v); + dontNotify.setChecked(dontNotify == v); + + if (defaultOption.isChecked()) selectionCallback.selection = MentionSetting.GLOBAL; + else if (alwaysNotify.isChecked()) selectionCallback.selection = MentionSetting.ALWAYS_NOTIFY; + else if (dontNotify.isChecked()) selectionCallback.selection = MentionSetting.DO_NOT_NOTIFY; + }; + + defaultOption.setOnClickListener(listener); + alwaysNotify.setOnClickListener(listener); + dontNotify.setOnClickListener(listener); + + switch (mentionSetting) { + case GLOBAL: + listener.onClick(defaultOption); + break; + case ALWAYS_NOTIFY: + listener.onClick(alwaysNotify); + break; + case DO_NOT_NOTIFY: + listener.onClick(dontNotify); + break; + } + + return root; + } + + private static class SelectionCallback implements DialogInterface.OnClickListener { + + @NonNull private final MentionSetting previousMentionSetting; + @NonNull private MentionSetting selection; + @Nullable private final Consumer callback; + + public SelectionCallback(@NonNull MentionSetting previousMentionSetting, @Nullable Consumer callback) { + this.previousMentionSetting = previousMentionSetting; + this.selection = previousMentionSetting; + this.callback = callback; + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (callback != null && selection != previousMentionSetting) { + callback.accept(selection); + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java index 16781b9e01..970c194cb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/v2/processing/GroupsV2StateProcessor.java @@ -232,6 +232,7 @@ public final class GroupsV2StateProcessor { false, null, Collections.emptyList(), + Collections.emptyList(), Collections.emptyList()); try { @@ -397,7 +398,7 @@ public final class GroupsV2StateProcessor { MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupId); Recipient recipient = Recipient.resolved(recipientId); - OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(recipient, decryptedGroupV2Context, null, timestamp, 0, false, null, Collections.emptyList(), Collections.emptyList()); + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(recipient, decryptedGroupV2Context, null, timestamp, 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); 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 ab47a28357..9f4c8d5235 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -286,6 +286,7 @@ public final class PushGroupSendJob extends PushSendJob { Optional sticker = getStickerFor(message); List sharedContacts = getSharedContactsFor(message); List previews = getPreviewsFor(message); + List mentions = getMentionsFor(message.getMentions()); List addresses = Stream.of(destinations).map(this::getPushAddress).toList(); List attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); List attachmentPointers = getAttachmentPointersFor(attachments); @@ -352,6 +353,7 @@ public final class PushGroupSendJob extends PushSendJob { .withSticker(sticker.orNull()) .withSharedContacts(sharedContacts) .withPreviews(previews) + .withMentions(mentions) .build(); Log.i(TAG, JobLogger.format(this, "Beginning message send.")); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index f295806343..f415a49552 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; @@ -123,6 +124,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMes import org.whispersystems.signalservice.api.messages.shared.SharedContact; import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; +import org.whispersystems.signalservice.api.util.UuidUtil; import java.io.IOException; import java.security.SecureRandom; @@ -133,6 +135,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.TimeUnit; public final class PushProcessMessageJob extends BaseJob { @@ -341,7 +344,7 @@ public final class PushProcessMessageJob extends BaseJob { if (content.getDataMessage().isPresent()) { SignalServiceDataMessage message = content.getDataMessage().get(); - boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent(); + boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent() || message.getMentions().isPresent(); Optional groupId = GroupUtil.idFromGroupContext(message.getGroupContext()); boolean isGv2Message = groupId.isPresent() && groupId.get().isV2(); @@ -729,6 +732,7 @@ public final class PushProcessMessageJob extends BaseJob { Optional.absent(), Optional.absent(), Optional.absent(), + Optional.absent(), Optional.absent()); database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -1029,6 +1033,7 @@ public final class PushProcessMessageJob extends BaseJob { Optional quote = getValidatedQuote(message.getQuote()); Optional> sharedContacts = getContacts(message.getSharedContacts()); Optional> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or("")); + Optional> mentions = getMentions(message.getMentions()); Optional sticker = getStickerAttachment(message.getSticker()); IncomingMediaMessage mediaMessage = new IncomingMediaMessage(RecipientId.fromHighTrust(content.getSender()), message.getTimestamp(), @@ -1044,6 +1049,7 @@ public final class PushProcessMessageJob extends BaseJob { quote, sharedContacts, linkPreviews, + mentions, sticker); insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); @@ -1109,6 +1115,7 @@ public final class PushProcessMessageJob extends BaseJob { Optional sticker = getStickerAttachment(message.getMessage().getSticker()); Optional> sharedContacts = getContacts(message.getMessage().getSharedContacts()); Optional> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or("")); + Optional> mentions = getMentions(message.getMessage().getMentions()); boolean viewOnce = message.getMessage().isViewOnce(); List syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false)) : PointerAttachment.forPointers(message.getMessage().getAttachments()); @@ -1125,6 +1132,7 @@ public final class PushProcessMessageJob extends BaseJob { ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(), sharedContacts.or(Collections.emptyList()), previews.or(Collections.emptyList()), + mentions.or(Collections.emptyList()), Collections.emptyList(), Collections.emptyList()); mediaMessage = new OutgoingSecureMediaMessage(mediaMessage); @@ -1290,7 +1298,18 @@ public final class PushProcessMessageJob extends BaseJob { long messageId; if (isGroup) { - OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, new SlideDeck(), body, message.getTimestamp(), -1, expiresInMillis, false, ThreadDatabase.DistributionTypes.DEFAULT, null, Collections.emptyList(), Collections.emptyList()); + OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, + new SlideDeck(), + body, + message.getTimestamp(), + -1, + expiresInMillis, + false, + ThreadDatabase.DistributionTypes.DEFAULT, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage); messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null); @@ -1577,10 +1596,13 @@ public final class PushProcessMessageJob extends BaseJob { Log.i(TAG, "Found matching message record..."); List attachments = new LinkedList<>(); + List mentions = new LinkedList<>(); if (message.isMms()) { MmsMessageRecord mmsMessage = (MmsMessageRecord) message; + mentions.addAll(DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(mmsMessage.getId())); + if (mmsMessage.isViewOnce()) { attachments.add(new TombstoneAttachment(MediaUtil.VIEW_ONCE, true)); } else { @@ -1595,7 +1617,7 @@ public final class PushProcessMessageJob extends BaseJob { } } - return Optional.of(new QuoteModel(quote.get().getId(), author, message.getBody(), false, attachments)); + return Optional.of(new QuoteModel(quote.get().getId(), author, message.getBody(), false, attachments, mentions)); } else if (message != null) { Log.w(TAG, "Found the target for the quote, but it's flagged as remotely deleted."); } @@ -1606,7 +1628,8 @@ public final class PushProcessMessageJob extends BaseJob { author, quote.get().getText(), true, - PointerAttachment.forPointers(quote.get().getAttachments()))); + PointerAttachment.forPointers(quote.get().getAttachments()), + getMentions(quote.get().getMentions()))); } private Optional getStickerAttachment(Optional sticker) { @@ -1685,6 +1708,26 @@ public final class PushProcessMessageJob extends BaseJob { return Optional.of(linkPreviews); } + private Optional> getMentions(Optional> signalServiceMentions) { + if (!signalServiceMentions.isPresent()) return Optional.absent(); + + return Optional.of(getMentions(signalServiceMentions.get())); + } + + private @NonNull List getMentions(@Nullable List signalServiceMentions) { + if (signalServiceMentions == null || signalServiceMentions.isEmpty()) { + return Collections.emptyList(); + } + + List mentions = new ArrayList<>(signalServiceMentions.size()); + + for (SignalServiceDataMessage.Mention mention : signalServiceMentions) { + mentions.add(new Mention(Recipient.externalPush(context, mention.getUuid(), null, false).getId(), mention.getStart(), mention.getLength())); + } + + return mentions; + } + private Optional insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp) { return insertPlaceholder(sender, senderDevice, timestamp, Optional.absent()); } 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 2ccd7812e5..bacdccfc17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactModelMapper; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.events.PartProgressEvent; import org.thoughtcrime.securesms.jobmanager.Job; @@ -239,6 +240,7 @@ public abstract class PushSendJob extends SendJob { long quoteId = message.getOutgoingQuote().getId(); String quoteBody = message.getOutgoingQuote().getText(); RecipientId quoteAuthor = message.getOutgoingQuote().getAuthor(); + List quoteMentions = getMentionsFor(message.getOutgoingQuote().getMentions()); List quoteAttachments = new LinkedList<>(); List filteredAttachments = Stream.of(message.getOutgoingQuote().getAttachments()) .filterNot(a -> MediaUtil.isViewOnceType(a.getContentType())) @@ -284,7 +286,7 @@ public abstract class PushSendJob extends SendJob { Recipient quoteAuthorRecipient = Recipient.resolved(quoteAuthor); SignalServiceAddress quoteAddress = RecipientUtil.toSignalServiceAddress(context, quoteAuthorRecipient); - return Optional.of(new SignalServiceDataMessage.Quote(quoteId, quoteAddress, quoteBody, quoteAttachments)); + return Optional.of(new SignalServiceDataMessage.Quote(quoteId, quoteAddress, quoteBody, quoteAttachments, quoteMentions)); } protected Optional getStickerFor(OutgoingMediaMessage message) { @@ -334,6 +336,12 @@ public abstract class PushSendJob extends SendJob { }).toList(); } + List getMentionsFor(@NonNull List mentions) { + return Stream.of(mentions) + .map(m -> new SignalServiceDataMessage.Mention(Recipient.resolved(m.getRecipientId()).requireUuid(), m.getStart(), m.getLength())) + .toList(); + } + protected void rotateSenderCertificateIfNecessary() throws IOException { try { byte[] certificateBytes = TextSecurePreferences.getUnidentifiedAccessCertificate(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/WakeGroupV2Job.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/WakeGroupV2Job.java index 31ca4bd092..2fc61fcd69 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/WakeGroupV2Job.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/WakeGroupV2Job.java @@ -98,7 +98,7 @@ public final class WakeGroupV2Job extends BaseJob { GroupDatabase.V2GroupProperties v2GroupProperties = group.get().requireV2GroupProperties(); DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(v2GroupProperties.getGroupMasterKey(), v2GroupProperties.getDecryptedGroup(), null, null); MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); - OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, decryptedGroupV2Context, null, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList()); + OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, decryptedGroupV2Context, null, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/NotificationSettings.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/NotificationSettings.java new file mode 100644 index 0000000000..80590400ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/NotificationSettings.java @@ -0,0 +1,20 @@ +package org.thoughtcrime.securesms.keyvalue; + +import androidx.annotation.NonNull; + +public class NotificationSettings extends SignalStoreValues { + + public static final String MENTIONS_NOTIFY_ME = "notifications.mentions.notify_me"; + + NotificationSettings(@NonNull KeyValueStore store) { + super(store); + } + + @Override + void onFirstEverAppLaunch() { + } + + public boolean isMentionNotifiesMeEnabled() { + return getBoolean(MENTIONS_NOTIFY_ME, true); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index 68deca7c21..5d45aac67c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -1,7 +1,6 @@ package org.thoughtcrime.securesms.keyvalue; import androidx.annotation.NonNull; -import androidx.lifecycle.OnLifecycleEvent; import androidx.preference.PreferenceDataStore; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -25,6 +24,7 @@ public final class SignalStore { private final MiscellaneousValues misc; private final InternalValues internalValues; private final EmojiValues emojiValues; + private final NotificationSettings notificationSettings; private SignalStore() { this.store = ApplicationDependencies.getKeyValueStore(); @@ -38,6 +38,7 @@ public final class SignalStore { this.misc = new MiscellaneousValues(store); this.internalValues = new InternalValues(store); this.emojiValues = new EmojiValues(store); + this.notificationSettings = new NotificationSettings(store); } public static void onFirstEverAppLaunch() { @@ -50,6 +51,7 @@ public final class SignalStore { tooltips().onFirstEverAppLaunch(); misc().onFirstEverAppLaunch(); internalValues().onFirstEverAppLaunch(); + notificationSettings().onFirstEverAppLaunch(); } public static @NonNull KbsValues kbsValues() { @@ -92,6 +94,10 @@ public final class SignalStore { return INSTANCE.emojiValues; } + public static @NonNull NotificationSettings notificationSettings() { + return INSTANCE.notificationSettings; + } + public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() { return new GroupsV2AuthorizationSignalStoreCache(getStore()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessage.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessage.java index 7369405d11..41a68b22ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessage.java @@ -1,28 +1,33 @@ package org.thoughtcrime.securesms.longmessage; +import android.content.Context; import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; /** - * A wrapper around a {@link MessageRecord} and its extra text attachment expanded into a string + * A wrapper around a {@link ConversationMessage} and its extra text attachment expanded into a string * held in memory. */ class LongMessage { - private final MessageRecord messageRecord; - private final String fullBody; + private final ConversationMessage conversationMessage; + private final String fullBody; - LongMessage(MessageRecord messageRecord, String fullBody) { - this.messageRecord = messageRecord; - this.fullBody = fullBody; + LongMessage(@NonNull ConversationMessage conversationMessage, @NonNull String fullBody) { + this.conversationMessage = conversationMessage; + this.fullBody = fullBody; } - MessageRecord getMessageRecord() { - return messageRecord; + @NonNull MessageRecord getMessageRecord() { + return conversationMessage.getMessageRecord(); } - String getFullBody() { - return !TextUtils.isEmpty(fullBody) ? fullBody : messageRecord.getBody(); + @NonNull CharSequence getFullBody(@NonNull Context context) { + return !TextUtils.isEmpty(fullBody) ? fullBody : conversationMessage.getDisplayBody(context); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java index d946d48a87..2653e2e1c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageActivity.java @@ -147,7 +147,7 @@ public class LongMessageActivity extends PassphraseRequiredActivity { TextView text = bubble.findViewById(R.id.longmessage_text); ConversationItemFooter footer = bubble.findViewById(R.id.longmessage_footer); - String trimmedBody = getTrimmedBody(message.get().getFullBody()); + CharSequence trimmedBody = getTrimmedBody(message.get().getFullBody(this)); SpannableString styledBody = linkifyMessageBody(new SpannableString(trimmedBody)); bubble.setVisibility(View.VISIBLE); @@ -158,9 +158,9 @@ public class LongMessageActivity extends PassphraseRequiredActivity { }); } - private String getTrimmedBody(@NonNull String text) { + private CharSequence getTrimmedBody(@NonNull CharSequence text) { return text.length() <= MAX_DISPLAY_LENGTH ? text - : text.substring(0, MAX_DISPLAY_LENGTH); + : text.subSequence(0, MAX_DISPLAY_LENGTH); } private SpannableString linkifyMessageBody(SpannableString messageBody) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java index 5b31a0ba8c..fa378c6208 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java @@ -6,6 +6,8 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; +import org.thoughtcrime.securesms.conversation.ConversationMessage; +import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase; @@ -38,7 +40,7 @@ class LongMessageRepository { if (isMms) { callback.onComplete(getMmsLongMessage(context, mmsDatabase, messageId)); } else { - callback.onComplete(getSmsLongMessage(smsDatabase, messageId)); + callback.onComplete(getSmsLongMessage(context, smsDatabase, messageId)); } }); } @@ -51,9 +53,9 @@ class LongMessageRepository { TextSlide textSlide = record.get().getSlideDeck().getTextSlide(); if (textSlide != null && textSlide.getUri() != null) { - return Optional.of(new LongMessage(record.get(), readFullBody(context, textSlide.getUri()))); + return Optional.of(new LongMessage(ConversationMessageFactory.createWithUnresolvedData(context, record.get()), readFullBody(context, textSlide.getUri()))); } else { - return Optional.of(new LongMessage(record.get(), "")); + return Optional.of(new LongMessage(ConversationMessageFactory.createWithUnresolvedData(context, record.get()), "")); } } else { return Optional.absent(); @@ -61,11 +63,11 @@ class LongMessageRepository { } @WorkerThread - private Optional getSmsLongMessage(@NonNull SmsDatabase smsDatabase, long messageId) { + private Optional getSmsLongMessage(@NonNull Context context, @NonNull SmsDatabase smsDatabase, long messageId) { Optional record = getSmsMessage(smsDatabase, messageId); if (record.isPresent()) { - return Optional.of(new LongMessage(record.get(), "")); + return Optional.of(new LongMessage(ConversationMessageFactory.createWithUnresolvedData(context, record.get()), "")); } else { return Optional.absent(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java index 46f4149681..1dc78d7a3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivity.java @@ -30,6 +30,9 @@ import androidx.lifecycle.ViewModelProviders; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import com.annimon.stream.Collectors; +import com.annimon.stream.Stream; + import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.TransportOption; @@ -42,7 +45,9 @@ import org.thoughtcrime.securesms.components.emoji.EmojiEditText; import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider; import org.thoughtcrime.securesms.components.emoji.EmojiToggle; import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; +import org.thoughtcrime.securesms.components.mention.MentionAnnotation; import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; +import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; import org.thoughtcrime.securesms.imageeditor.model.EditorModel; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; @@ -55,6 +60,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.Function3; import org.thoughtcrime.securesms.util.IOFunction; import org.thoughtcrime.securesms.util.MediaUtil; @@ -76,6 +82,7 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; /** * Encompasses the entire flow of sending media, starting from the selection process to the actual @@ -130,6 +137,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med private EmojiEditText captionText; private EmojiToggle emojiToggle; private Stub emojiDrawer; + private Stub mentionSuggestions; private TextView charactersLeft; private RecyclerView mediaRail; private MediaRailAdapter mediaRailAdapter; @@ -142,7 +150,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med /** * Get an intent to launch the media send flow starting with the picker. */ - public static Intent buildGalleryIntent(@NonNull Context context, @NonNull Recipient recipient, @Nullable String body, @NonNull TransportOption transport) { + public static Intent buildGalleryIntent(@NonNull Context context, @NonNull Recipient recipient, @Nullable CharSequence body, @NonNull TransportOption transport) { Intent intent = new Intent(context, MediaSendActivity.class); intent.putExtra(KEY_RECIPIENT, recipient.getId()); intent.putExtra(KEY_TRANSPORT, transport); @@ -174,7 +182,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med public static Intent buildEditorIntent(@NonNull Context context, @NonNull List media, @NonNull Recipient recipient, - @NonNull String body, + @NonNull CharSequence body, @NonNull TransportOption transport) { Intent intent = buildGalleryIntent(context, recipient, body, transport); @@ -207,6 +215,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med charactersLeft = findViewById(R.id.mediasend_characters_left); mediaRail = findViewById(R.id.mediasend_media_rail); emojiDrawer = new Stub<>(findViewById(R.id.mediasend_emoji_drawer_stub)); + mentionSuggestions = new Stub<>(findViewById(R.id.mediasend_mention_suggestions_stub)); RecipientId recipientId = getIntent().getParcelableExtra(KEY_RECIPIENT); if (recipientId != null) { @@ -222,7 +231,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med viewModel.setTransport(transport); viewModel.setRecipient(recipient != null ? recipient.get() : null); - viewModel.onBodyChanged(getIntent().getStringExtra(KEY_BODY)); + viewModel.onBodyChanged(getIntent().getCharSequenceExtra(KEY_BODY)); List media = getIntent().getParcelableArrayListExtra(KEY_MEDIA); boolean isCamera = getIntent().getBooleanExtra(KEY_IS_CAMERA, false); @@ -309,6 +318,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med } initViewModel(); + if (FeatureFlags.mentions()) initializeMentionsViewModel(); revealButton.setOnClickListener(v -> viewModel.onRevealButtonToggled()); continueButton.setOnClickListener(v -> navigateToContactSelect()); @@ -525,7 +535,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med MediaSendFragment fragment = getMediaSendFragment(); if (fragment != null) { - viewModel.onSendClicked(buildModelsToTransform(fragment), recipients).observe(this, result -> { + viewModel.onSendClicked(buildModelsToTransform(fragment), recipients, composeText.getMentions()).observe(this, result -> { finish(); }); } else { @@ -546,7 +556,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med sendButton.setEnabled(false); - viewModel.onSendClicked(buildModelsToTransform(fragment), Collections.emptyList()).observe(this, this::setActivityResultAndFinish); + viewModel.onSendClicked(buildModelsToTransform(fragment), Collections.emptyList(), composeText.getMentions()).observe(this, this::setActivityResultAndFinish); } private static Map buildModelsToTransform(@NonNull MediaSendFragment fragment) { @@ -751,6 +761,46 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med }); } + private void initializeMentionsViewModel() { + if (recipient == null) { + return; + } + + MentionsPickerViewModel mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class); + + recipient.observe(this, mentionsViewModel::onRecipientChange); + composeText.setMentionQueryChangedListener(query -> { + if (recipient.get().isPushV2Group()) { + if (!mentionSuggestions.resolved()) { + mentionSuggestions.get(); + } + mentionsViewModel.onQueryChange(query); + } + }); + + composeText.setMentionValidator(annotations -> { + if (!recipient.get().isPushV2Group()) { + return annotations; + } + + Set validRecipientIds = Stream.of(recipient.get().getParticipants()) + .map(r -> MentionAnnotation.idToMentionAnnotationValue(r.getId())) + .collect(Collectors.toSet()); + + return Stream.of(annotations) + .filter(a -> !validRecipientIds.contains(a.getValue())) + .toList(); + }); + + mentionsViewModel.getSelectedRecipient().observe(this, recipient -> { + String replacementDisplayName = recipient.getDisplayName(this); + if (replacementDisplayName.equals(recipient.getDisplayUsername())) { + replacementDisplayName = recipient.getUsername().or(replacementDisplayName); + } + composeText.replaceTextWithMention(replacementDisplayName, recipient.getId()); + }); + } + private void presentRecipient(@Nullable Recipient recipient) { if (recipient == null) { composeText.setHint(R.string.MediaSendActivity_message); @@ -836,9 +886,9 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med private void presentCharactersRemaining() { - String messageBody = composeText.getTextTrimmed(); + String messageBody = composeText.getTextTrimmed().toString(); TransportOption transportOption = sendButton.getSelectedTransport(); - CharacterState characterState = transportOption.calculateCharacters(messageBody); + CharacterState characterState = transportOption.calculateCharacters(messageBody); if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { charactersLeft.setText(String.format(Locale.getDefault(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java index 7f1fc6afd0..5750516d5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.java @@ -7,6 +7,7 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult; import org.thoughtcrime.securesms.util.ParcelUtil; import org.whispersystems.libsignal.util.guava.Preconditions; @@ -24,36 +25,41 @@ public class MediaSendActivityResult implements Parcelable { private final String body; private final TransportOption transport; private final boolean viewOnce; + private final Collection mentions; static @NonNull MediaSendActivityResult forPreUpload(@NonNull Collection uploadResults, @NonNull String body, @NonNull TransportOption transport, - boolean viewOnce) + boolean viewOnce, + @NonNull List mentions) { Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!"); - return new MediaSendActivityResult(uploadResults, Collections.emptyList(), body, transport, viewOnce); + return new MediaSendActivityResult(uploadResults, Collections.emptyList(), body, transport, viewOnce, mentions); } static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull List nonUploadedMedia, @NonNull String body, @NonNull TransportOption transport, - boolean viewOnce) + boolean viewOnce, + @NonNull List mentions) { Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!"); - return new MediaSendActivityResult(Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce); + return new MediaSendActivityResult(Collections.emptyList(), nonUploadedMedia, body, transport, viewOnce, mentions); } private MediaSendActivityResult(@NonNull Collection uploadResults, @NonNull List nonUploadedMedia, @NonNull String body, @NonNull TransportOption transport, - boolean viewOnce) + boolean viewOnce, + @NonNull List mentions) { this.uploadResults = uploadResults; this.nonUploadedMedia = nonUploadedMedia; this.body = body; this.transport = transport; this.viewOnce = viewOnce; + this.mentions = mentions; } private MediaSendActivityResult(Parcel in) { @@ -62,6 +68,7 @@ public class MediaSendActivityResult implements Parcelable { this.body = in.readString(); this.transport = in.readParcelable(TransportOption.class.getClassLoader()); this.viewOnce = ParcelUtil.readBoolean(in); + this.mentions = ParcelUtil.readParcelableCollection(in, Mention.class); } public boolean isPushPreUpload() { @@ -88,6 +95,10 @@ public class MediaSendActivityResult implements Parcelable { return viewOnce; } + public @NonNull Collection getMentions() { + return mentions; + } + public static final Creator CREATOR = new Creator() { @Override public MediaSendActivityResult createFromParcel(Parcel in) { @@ -112,5 +123,6 @@ public class MediaSendActivityResult implements Parcelable { dest.writeString(body); dest.writeParcelable(transport, 0); ParcelUtil.writeBoolean(dest, viewOnce); + ParcelUtil.writeParcelableCollection(dest, mentions); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java index a5eb3edd52..fc54acba4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendViewModel.java @@ -17,6 +17,7 @@ import com.annimon.stream.Stream; import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; @@ -453,7 +454,7 @@ class MediaSendViewModel extends ViewModel { savedDrawState.putAll(state); } - @NonNull LiveData onSendClicked(Map modelsToTransform, @NonNull List recipients) { + @NonNull LiveData onSendClicked(Map modelsToTransform, @NonNull List recipients, @NonNull List mentions) { if (isSms && recipients.size() > 0) { throw new IllegalStateException("Provided recipients to send to, but this is SMS!"); } @@ -476,7 +477,7 @@ class MediaSendViewModel extends ViewModel { if (isSms || MessageSender.isLocalSelfSend(application, recipient, isSms)) { Log.i(TAG, "SMS or local self-send. Skipping pre-upload."); - result.postValue(MediaSendActivityResult.forTraditionalSend(updatedMedia, trimmedBody, transport, isViewOnce())); + result.postValue(MediaSendActivityResult.forTraditionalSend(updatedMedia, trimmedBody, transport, isViewOnce(), mentions)); return; } @@ -493,12 +494,12 @@ class MediaSendViewModel extends ViewModel { uploadRepository.updateDisplayOrder(updatedMedia); uploadRepository.getPreUploadResults(uploadResults -> { if (recipients.size() > 0) { - sendMessages(recipients, splitBody, uploadResults); + sendMessages(recipients, splitBody, uploadResults, mentions); uploadRepository.deleteAbandonedAttachments(); } Util.cancelRunnableOnMain(dialogRunnable); - result.postValue(MediaSendActivityResult.forPreUpload(uploadResults, splitBody, transport, isViewOnce())); + result.postValue(MediaSendActivityResult.forPreUpload(uploadResults, splitBody, transport, isViewOnce(), mentions)); }); }); @@ -632,7 +633,7 @@ class MediaSendViewModel extends ViewModel { } @WorkerThread - private void sendMessages(@NonNull List recipients, @NonNull String body, @NonNull Collection preUploadResults) { + private void sendMessages(@NonNull List recipients, @NonNull String body, @NonNull Collection preUploadResults, @NonNull List mentions) { List messages = new ArrayList<>(recipients.size()); for (Recipient recipient : recipients) { @@ -647,6 +648,7 @@ class MediaSendViewModel extends ViewModel { null, Collections.emptyList(), Collections.emptyList(), + mentions, Collections.emptyList(), Collections.emptyList()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetails.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetails.java index 1525309399..9bd419f65f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetails.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import com.annimon.stream.ComparatorCompat; +import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -17,7 +18,7 @@ final class MessageDetails { private static final Comparator ALPHABETICAL = (r1, r2) -> r1.getRecipient().getDisplayName(ApplicationDependencies.getApplication()).compareToIgnoreCase(r2.getRecipient().getDisplayName(ApplicationDependencies.getApplication())); private static final Comparator RECIPIENT_COMPARATOR = ComparatorCompat.chain(HAS_DISPLAY_NAME).thenComparing(ALPHABETICAL); - private final MessageRecord messageRecord; + private final ConversationMessage conversationMessage; private final Collection pending; private final Collection sent; @@ -25,8 +26,8 @@ final class MessageDetails { private final Collection read; private final Collection notSent; - MessageDetails(MessageRecord messageRecord, List recipients) { - this.messageRecord = messageRecord; + MessageDetails(@NonNull ConversationMessage conversationMessage, @NonNull List recipients) { + this.conversationMessage = conversationMessage; pending = new TreeSet<>(RECIPIENT_COMPARATOR); sent = new TreeSet<>(RECIPIENT_COMPARATOR); @@ -34,7 +35,7 @@ final class MessageDetails { read = new TreeSet<>(RECIPIENT_COMPARATOR); notSent = new TreeSet<>(RECIPIENT_COMPARATOR); - if (messageRecord.isOutgoing()) { + if (conversationMessage.getMessageRecord().isOutgoing()) { for (RecipientDeliveryStatus status : recipients) { switch (status.getDeliveryStatus()) { case UNKNOWN: @@ -59,8 +60,8 @@ final class MessageDetails { } } - @NonNull MessageRecord getMessageRecord() { - return messageRecord; + @NonNull ConversationMessage getConversationMessage() { + return conversationMessage; } @NonNull Collection getPending() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java index 3f763d5ca7..193a3f4137 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsActivity.java @@ -124,7 +124,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { assert getSupportActionBar() != null; getSupportActionBar().setBackgroundDrawable(new ColorDrawable(color.toActionBarColor(this))); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (Build.VERSION.SDK_INT >= 21) { getWindow().setStatusBarColor(color.toStatusBarColor(this)); } } @@ -132,9 +132,9 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity { private List> convertToRows(MessageDetails details) { List> list = new ArrayList<>(); - list.add(new MessageDetailsViewState<>(details.getMessageRecord(), MessageDetailsViewState.MESSAGE_HEADER)); + list.add(new MessageDetailsViewState<>(details.getConversationMessage(), MessageDetailsViewState.MESSAGE_HEADER)); - if (details.getMessageRecord().isOutgoing()) { + if (details.getConversationMessage().getMessageRecord().isOutgoing()) { addRecipients(list, RecipientHeader.NOT_SENT, details.getNotSent()); addRecipients(list, RecipientHeader.READ, details.getRead()); addRecipients(list, RecipientHeader.DELIVERED, details.getDelivered()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java index b7f3d09675..92fd33068f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java @@ -10,6 +10,7 @@ import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.mms.GlideRequests; @@ -45,7 +46,7 @@ final class MessageDetailsAdapter extends ListAdapter(), messageRecord.getRecipient(), null, false); + conversationItem.bind(conversationMessage, Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), conversationMessage.getMessageRecord().getRecipient(), null, false); } private void bindErrorState(MessageRecord messageRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 1d5b5dae3f..ea205105d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -377,7 +377,7 @@ public class AttachmentManager { .execute(); } - public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body, @NonNull TransportOption transport) { + public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull CharSequence body, @NonNull TransportOption transport) { Permissions.with(activity) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .ifNecessary() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java index 59504cf11f..b7fc59f38d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/IncomingMediaMessage.java @@ -5,6 +5,7 @@ import androidx.annotation.NonNull; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.PointerAttachment; import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -35,6 +36,7 @@ public class IncomingMediaMessage { private final List attachments = new LinkedList<>(); private final List sharedContacts = new LinkedList<>(); private final List linkPreviews = new LinkedList<>(); + private final List mentions = new LinkedList<>(); public IncomingMediaMessage(@NonNull RecipientId from, Optional groupId, @@ -78,6 +80,7 @@ public class IncomingMediaMessage { Optional quote, Optional> sharedContacts, Optional> linkPreviews, + Optional> mentions, Optional sticker) { this.push = true; @@ -98,6 +101,7 @@ public class IncomingMediaMessage { this.attachments.addAll(PointerAttachment.forPointers(attachments)); this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList())); this.linkPreviews.addAll(linkPreviews.or(Collections.emptyList())); + this.mentions.addAll(mentions.or(Collections.emptyList())); if (sticker.isPresent()) { this.attachments.add(sticker.get()); @@ -164,6 +168,10 @@ public class IncomingMediaMessage { return linkPreviews; } + public @NonNull List getMentions() { + return mentions; + } + public boolean isUnidentified() { return unidentified; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java index fd86024544..1efad1d5d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingExpirationUpdateMessage.java @@ -12,7 +12,7 @@ public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn) { super(recipient, "", new LinkedList(), sentTimeMillis, ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, false, null, Collections.emptyList(), - Collections.emptyList()); + Collections.emptyList(), Collections.emptyList()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java index f48bee70a7..3f72e8a827 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingGroupUpdateMessage.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.database.ThreadDatabase; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.recipients.Recipient; @@ -26,10 +27,11 @@ public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage boolean viewOnce, @Nullable QuoteModel quote, @NonNull List contacts, - @NonNull List previews) + @NonNull List previews, + @NonNull List mentions) { super(recipient, groupContext.getEncodedGroupContext(), avatar, sentTimeMillis, - ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, viewOnce, quote, contacts, previews); + ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, viewOnce, quote, contacts, previews, mentions); this.messageGroupContext = groupContext; } @@ -42,9 +44,10 @@ public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage boolean viewOnce, @Nullable QuoteModel quote, @NonNull List contacts, - @NonNull List previews) + @NonNull List previews, + @NonNull List mentions) { - this(recipient, new MessageGroupContext(group), getAttachments(avatar), sentTimeMillis, expireIn, viewOnce, quote, contacts, previews); + this(recipient, new MessageGroupContext(group), getAttachments(avatar), sentTimeMillis, expireIn, viewOnce, quote, contacts, previews, mentions); } public OutgoingGroupUpdateMessage(@NonNull Recipient recipient, @@ -55,9 +58,10 @@ public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage boolean viewOnce, @Nullable QuoteModel quote, @NonNull List contacts, - @NonNull List previews) + @NonNull List previews, + @NonNull List mentions) { - this(recipient, new MessageGroupContext(group), getAttachments(avatar), sentTimeMillis, expireIn, viewOnce, quote, contacts, previews); + this(recipient, new MessageGroupContext(group), getAttachments(avatar), sentTimeMillis, expireIn, viewOnce, quote, contacts, previews, mentions); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java index d9d02b62f8..75093f8e10 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMediaMessage.java @@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.NetworkFailure; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.recipients.Recipient; @@ -30,6 +31,7 @@ public class OutgoingMediaMessage { private final List identityKeyMismatches = new LinkedList<>(); private final List contacts = new LinkedList<>(); private final List linkPreviews = new LinkedList<>(); + private final List mentions = new LinkedList<>(); public OutgoingMediaMessage(Recipient recipient, String message, List attachments, long sentTimeMillis, @@ -38,6 +40,7 @@ public class OutgoingMediaMessage { @Nullable QuoteModel outgoingQuote, @NonNull List contacts, @NonNull List linkPreviews, + @NonNull List mentions, @NonNull List networkFailures, @NonNull List identityKeyMismatches) { @@ -53,6 +56,7 @@ public class OutgoingMediaMessage { this.contacts.addAll(contacts); this.linkPreviews.addAll(linkPreviews); + this.mentions.addAll(mentions); this.networkFailures.addAll(networkFailures); this.identityKeyMismatches.addAll(identityKeyMismatches); } @@ -62,14 +66,15 @@ public class OutgoingMediaMessage { boolean viewOnce, int distributionType, @Nullable QuoteModel outgoingQuote, @NonNull List contacts, - @NonNull List linkPreviews) + @NonNull List linkPreviews, + @NonNull List mentions) { this(recipient, buildMessage(slideDeck, message), slideDeck.asAttachments(), sentTimeMillis, subscriptionId, expiresIn, viewOnce, distributionType, outgoingQuote, - contacts, linkPreviews, new LinkedList<>(), new LinkedList<>()); + contacts, linkPreviews, mentions, new LinkedList<>(), new LinkedList<>()); } public OutgoingMediaMessage(OutgoingMediaMessage that) { @@ -87,6 +92,7 @@ public class OutgoingMediaMessage { this.networkFailures.addAll(that.networkFailures); this.contacts.addAll(that.contacts); this.linkPreviews.addAll(that.linkPreviews); + this.mentions.addAll(that.mentions); } public Recipient getRecipient() { @@ -145,6 +151,10 @@ public class OutgoingMediaMessage { return linkPreviews; } + public @NonNull List getMentions() { + return mentions; + } + public @NonNull List getNetworkFailures() { return networkFailures; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java index 2aec2a3400..4fd014cc1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingSecureMediaMessage.java @@ -5,6 +5,7 @@ import androidx.annotation.Nullable; import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.contactshare.Contact; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.recipients.Recipient; @@ -21,9 +22,10 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage { boolean viewOnce, @Nullable QuoteModel quote, @NonNull List contacts, - @NonNull List previews) + @NonNull List previews, + @NonNull List mentions) { - super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, viewOnce, distributionType, quote, contacts, previews, Collections.emptyList(), Collections.emptyList()); + super(recipient, body, attachments, sentTimeMillis, -1, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions, Collections.emptyList(), Collections.emptyList()); } public OutgoingSecureMediaMessage(OutgoingMediaMessage base) { 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 2261cfdf37..f1789cf60e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java @@ -5,8 +5,10 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.thoughtcrime.securesms.attachments.Attachment; +import org.thoughtcrime.securesms.database.model.Mention; import org.thoughtcrime.securesms.recipients.RecipientId; +import java.util.Collections; import java.util.List; public class QuoteModel { @@ -16,13 +18,15 @@ public class QuoteModel { private final String text; private final boolean missing; private final List attachments; + private final List mentions; - public QuoteModel(long id, @NonNull RecipientId author, String text, boolean missing, @Nullable List attachments) { + public QuoteModel(long id, @NonNull RecipientId author, String text, boolean missing, @Nullable List attachments, @Nullable List mentions) { this.id = id; this.author = author; this.text = text; this.missing = missing; this.attachments = attachments; + this.mentions = mentions != null ? mentions : Collections.emptyList(); } public long getId() { @@ -44,4 +48,8 @@ public class QuoteModel { public List getAttachments() { return attachments; } + + public @NonNull List getMentions() { + return mentions; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index 8bcb4cfb05..013e65a792 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -75,7 +75,20 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { if (recipient.resolve().isGroup()) { Log.w(TAG, "GroupRecipient, Sending media message"); - OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, false, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, + responseText.toString(), + new LinkedList<>(), + System.currentTimeMillis(), + subscriptionId, + expiresIn, + false, + 0, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); replyThreadId = MessageSender.send(context, reply, threadId, false, null); } else { Log.w(TAG, "Sending regular message "); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 0e67d25b49..5ae4afac32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -43,15 +43,18 @@ import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.MentionUtil; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.ThreadBodyUtil; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.messages.IncomingMessageObserver; import org.thoughtcrime.securesms.mms.Slide; @@ -59,6 +62,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.service.KeyCachingService; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.ServiceUtil; @@ -243,18 +247,14 @@ public class DefaultMessageNotifier implements MessageNotifier { { boolean isVisible = visibleThread == threadId; - ThreadDatabase threads = DatabaseFactory.getThreadDatabase(context); - Recipient recipients = DatabaseFactory.getThreadDatabase(context) - .getRecipientForThreadId(threadId); + ThreadDatabase threads = DatabaseFactory.getThreadDatabase(context); if (isVisible) { List messageIds = threads.setRead(threadId, false); MarkReadReceiver.process(context, messageIds); } - if (!TextSecurePreferences.isNotificationsEnabled(context) || - (recipients != null && recipients.isMuted())) - { + if (!TextSecurePreferences.isNotificationsEnabled(context)) { return; } @@ -499,7 +499,7 @@ public class DefaultMessageNotifier implements MessageNotifier { Recipient recipient = record.getIndividualRecipient().resolve(); Recipient conversationRecipient = record.getRecipient().resolve(); long threadId = record.getThreadId(); - CharSequence body = record.getDisplayBody(context); + CharSequence body = MentionUtil.updateBodyWithDisplayNames(context, record); Recipient threadRecipients = null; SlideDeck slideDeck = null; long timestamp = record.getTimestamp(); @@ -527,7 +527,17 @@ public class DefaultMessageNotifier implements MessageNotifier { slideDeck = ((MmsMessageRecord) record).getSlideDeck(); } - if (threadRecipients == null || !threadRecipients.isMuted()) { + boolean includeMessage = true; + if (threadRecipients != null && threadRecipients.isMuted()) { + RecipientDatabase.MentionSetting mentionSetting = threadRecipients.getMentionSetting(); + + boolean overrideMuted = (mentionSetting == RecipientDatabase.MentionSetting.GLOBAL && SignalStore.notificationSettings().isMentionNotifiesMeEnabled()) || + mentionSetting == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY; + + includeMessage = FeatureFlags.mentions() && overrideMuted && record.hasSelfMention(); + } + + if (threadRecipients == null || includeMessage) { notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, receivedTimestamp, slideDeck, false)); } } 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 dc7cb25f35..41f29a3d34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -77,7 +77,20 @@ public class RemoteReplyReceiver extends BroadcastReceiver { switch (replyMethod) { case GroupMessage: { - OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, false, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, + responseText.toString(), + new LinkedList<>(), + System.currentTimeMillis(), + subscriptionId, + expiresIn, + false, + 0, + null, + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList(), + Collections.emptyList()); threadId = MessageSender.send(context, reply, -1, false, null); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java index 6980d126d3..3c5005c69f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java @@ -12,13 +12,18 @@ import android.provider.Settings; import androidx.annotation.Nullable; import androidx.preference.ListPreference; import androidx.preference.Preference; +import androidx.preference.PreferenceDataStore; + import android.text.TextUtils; import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.keyvalue.NotificationSettings; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import static android.app.Activity.RESULT_OK; @@ -115,11 +120,18 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme initializeCallRingtoneSummary(findPreference(TextSecurePreferences.CALL_RINGTONE_PREF)); initializeMessageVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.VIBRATE_PREF)); initializeCallVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_VIBRATE_PREF)); + + if (FeatureFlags.mentions()) { + initializeMentionsNotifyMeSummary((SwitchPreferenceCompat)findPreference(NotificationSettings.MENTIONS_NOTIFY_ME)); + } } @Override public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { addPreferencesFromResource(R.xml.preferences_notifications); + if (FeatureFlags.mentions()) { + addPreferencesFromResource(R.xml.preferences_notifications_mentions); + } } @Override @@ -197,6 +209,11 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme pref.setChecked(TextSecurePreferences.isCallNotificationVibrateEnabled(getContext())); } + private void initializeMentionsNotifyMeSummary(SwitchPreferenceCompat pref) { + pref.setPreferenceDataStore(SignalStore.getPreferenceDataStore()); + pref.setChecked(SignalStore.notificationSettings().isMentionNotifiesMeEnabled()); + } + public static CharSequence getSummary(Context context) { final int onCapsResId = R.string.ApplicationPreferencesActivity_On; final int offCapsResId = R.string.ApplicationPreferencesActivity_Off; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java index e5eeb6b130..a58741f26e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/Recipient.java @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.contacts.avatars.TransparentContactPhoto; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; import org.thoughtcrime.securesms.database.RecipientDatabase; +import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; @@ -102,6 +103,7 @@ public class Recipient { private final byte[] storageId; private final byte[] identityKey; private final VerifiedStatus identityStatus; + private final MentionSetting mentionSetting; /** @@ -316,6 +318,7 @@ public class Recipient { this.storageId = null; this.identityKey = null; this.identityStatus = VerifiedStatus.DEFAULT; + this.mentionSetting = MentionSetting.GLOBAL; } public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) { @@ -359,6 +362,7 @@ public class Recipient { this.storageId = details.storageId; this.identityKey = details.identityKey; this.identityStatus = details.identityStatus; + this.mentionSetting = details.mentionSetting; } public @NonNull RecipientId getId() { @@ -809,6 +813,10 @@ public class Recipient { } } + public @NonNull MentionSetting getMentionSetting() { + return mentionSetting; + } + @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java index 4c08f66228..df30570e1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientDetails.java @@ -9,7 +9,9 @@ import androidx.annotation.Nullable; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; +import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.InsightsBannerTier; +import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting; import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; @@ -66,6 +68,7 @@ public class RecipientDetails { final byte[] storageId; final byte[] identityKey; final VerifiedStatus identityStatus; + final MentionSetting mentionSetting; public RecipientDetails(@Nullable String name, @NonNull Optional groupAvatarId, @@ -112,6 +115,7 @@ public class RecipientDetails { this.storageId = settings.getStorageId(); this.identityKey = settings.getIdentityKey(); this.identityStatus = settings.getIdentityStatus(); + this.mentionSetting = settings.getMentionSetting(); if (name == null) this.name = settings.getSystemDisplayName(); else this.name = name; @@ -160,6 +164,7 @@ public class RecipientDetails { this.storageId = null; this.identityKey = null; this.identityStatus = VerifiedStatus.DEFAULT; + this.mentionSetting = MentionSetting.GLOBAL; } public static @NonNull RecipientDetails forIndividual(@NonNull Context context, @NonNull RecipientSettings settings) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java index e66f6d8937..bf89386a50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java @@ -296,7 +296,7 @@ public class ShareActivity extends PassphraseRequiredActivity private void openConversation(long threadId, @NonNull RecipientId recipientId, @Nullable ShareData shareData) { Intent intent = new Intent(this, ConversationActivity.class); - String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT); + CharSequence textExtra = getIntent().getCharSequenceExtra(Intent.EXTRA_TEXT); ArrayList mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA); StickerLocator stickerExtra = getIntent().getParcelableExtra(ConversationActivity.STICKER_EXTRA); boolean borderlessExtra = getIntent().getBooleanExtra(ConversationActivity.BORDERLESS_EXTRA, false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java index 779bf39619..64d2064121 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/StringUtil.java @@ -180,4 +180,21 @@ public final class StringUtil { .appendCodePoint(Bidi.PDI) .toString(); } + + /** + * Trims a {@link CharSequence} of starting and trailing whitespace. Behavior matches + * {@link String#trim()} to preserve expectations around results. + */ + public static CharSequence trimSequence(CharSequence text) { + int length = text.length(); + int startIndex = 0; + + while ((startIndex < length) && (text.charAt(startIndex) <= ' ')) { + startIndex++; + } + while ((startIndex < length) && (text.charAt(length - 1) <= ' ')) { + length--; + } + return (startIndex > 0 || length < text.length()) ? text.subSequence(startIndex, length) : text; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index b5a2e55564..de1bd52669 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -176,6 +176,10 @@ public class Util { return value != null ? value : ""; } + public static @NonNull CharSequence emptyIfNull(@Nullable CharSequence value) { + return value != null ? value : ""; + } + public static List> chunk(@NonNull List list, int chunkSize) { List> chunks = new ArrayList<>(list.size() / chunkSize); diff --git a/app/src/main/proto/Database.proto b/app/src/main/proto/Database.proto index 5bbbfbd770..df6edc9d52 100644 --- a/app/src/main/proto/Database.proto +++ b/app/src/main/proto/Database.proto @@ -55,3 +55,16 @@ message ProfileChangeDetails { StringChange profileNameChange = 1; } + +message BodyRangeList { + message BodyRange { + int32 start = 1; + int32 length = 2; + + oneof associatedValue { + string mentionUuid = 3; + } + } + + repeated BodyRange ranges = 1; +} diff --git a/app/src/main/res/layout/group_manage_fragment.xml b/app/src/main/res/layout/group_manage_fragment.xml index dcea223f9a..4b0e0f9602 100644 --- a/app/src/main/res/layout/group_manage_fragment.xml +++ b/app/src/main/res/layout/group_manage_fragment.xml @@ -290,6 +290,46 @@ + + + + + + + + diff --git a/app/src/main/res/layout/group_mention_setting_dialog.xml b/app/src/main/res/layout/group_mention_setting_dialog.xml new file mode 100644 index 0000000000..e10bc5b70c --- /dev/null +++ b/app/src/main/res/layout/group_mention_setting_dialog.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/mediasend_activity.xml b/app/src/main/res/layout/mediasend_activity.xml index ea2bdb7628..25aa22a2b3 100644 --- a/app/src/main/res/layout/mediasend_activity.xml +++ b/app/src/main/res/layout/mediasend_activity.xml @@ -27,6 +27,13 @@ android:clickable="true" android:background="@color/transparent_black_40"> + + + tools:backgroundTint="@color/core_ultramarine"> + android:background="?conversation_mention_divider_color"/> + android:paddingStart="12dp" + android:paddingTop="10dp" + android:paddingEnd="8dp" + android:paddingBottom="10dp"> + + diff --git a/app/src/main/res/layout/quote_view.xml b/app/src/main/res/layout/quote_view.xml index ca27b3dc5c..8e3438b1af 100644 --- a/app/src/main/res/layout/quote_view.xml +++ b/app/src/main/res/layout/quote_view.xml @@ -98,6 +98,7 @@ style="@style/Signal.Text.Body" android:ellipsize="end" android:maxLines="2" + app:emoji_renderMentions="false" tools:text="With great power comes great responsibility." tools:visibility="visible" /> diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index a5b510fb19..b6161feb6a 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -90,6 +90,7 @@ + @@ -404,6 +405,7 @@ + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 6ba6768e3b..f6c1fd0294 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -159,4 +159,6 @@ 16dp 2dp + + 216dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 73290ae2d4..bff77016f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -549,6 +549,7 @@ Leave group Mute notifications Custom notifications + Mentions Until %1$s Off On @@ -576,6 +577,13 @@ Legacy Group This is a Legacy Group. To access features like group admins, create a New Group. + + Notify me for Mentions + Receive notifications when you’re mentioned in muted chats? + Default (Notify me) + Default (Don\'t notify me) + Always notify me + Don\'t notify me Add to system contacts @@ -1995,6 +2003,9 @@ Allow from anyone Enable sealed sender for incoming messages from non-contacts and people with whom you have not shared your profile. Learn more + Mentions + Notify me + Receive notifications when you’re mentioned in muted chats Internal Preferences diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2aa8c29e34..6ce46e3518 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -263,6 +263,7 @@ @color/white @color/transparent_white_90 @color/core_grey_20 + @color/core_grey_05 @color/core_grey_05 @color/core_ultramarine @@ -622,6 +623,7 @@ @color/transparent_white_90 @color/transparent_white_80 @color/core_grey_75 + @color/core_grey_25 @drawable/scroll_to_bottom_background_dark @color/core_white diff --git a/app/src/main/res/xml/preferences_notifications_mentions.xml b/app/src/main/res/xml/preferences_notifications_mentions.xml new file mode 100644 index 0000000000..09e27bc1d6 --- /dev/null +++ b/app/src/main/res/xml/preferences_notifications_mentions.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/MentionUtilTest_updateBodyAndMentionsWithPlaceholders.java b/app/src/test/java/org/thoughtcrime/securesms/database/MentionUtilTest_updateBodyAndMentionsWithPlaceholders.java new file mode 100644 index 0000000000..37005c9941 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/database/MentionUtilTest_updateBodyAndMentionsWithPlaceholders.java @@ -0,0 +1,121 @@ +package org.thoughtcrime.securesms.database; + +import android.app.Application; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.ParameterizedRobolectricTestRunner; +import org.robolectric.ParameterizedRobolectricTestRunner.Parameters; +import org.robolectric.annotation.Config; +import org.thoughtcrime.securesms.database.model.Mention; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Random; + +import static org.junit.Assert.assertEquals; +import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_PLACEHOLDER; +import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER; + +@RunWith(ParameterizedRobolectricTestRunner.class) +@Config(manifest = Config.NONE, application = Application.class) +public class MentionUtilTest_updateBodyAndMentionsWithPlaceholders { + + private final String body; + private final List mentions; + private final String updatedBody; + private final List updatedMentions; + + @Parameters + public static Collection data() { + return Arrays.asList(new Object[][]{ + /* Empty states */ + { null, Collections.emptyList(), null, Collections.emptyList() }, + builder().text("no mentions").build(), + builder().text("").build(), + builder().text("no mentions but @tester text").build(), + + /* Singles */ + builder().mention("test").text(" start").build(), + builder().text("middle ").mention("test").text(" middle").build(), + builder().text("end end ").mention("test").build(), + builder().mention("test").build(), + + /* Doubles */ + builder().mention("foo").text(" ").mention("barbaz").build(), + builder().text("start text ").mention("barbazbuzz").text(" ").mention("barbaz").build(), + builder().text("what what ").mention("foo").text(" ").mention("barbaz").text(" more text").build(), + builder().mention("barbazbuzz").text(" ").mention("foo").build(), + + /* Triples */ + builder().mention("test").text(" ").mention("test2").text(" ").mention("test3").build(), + builder().text("Starting ").mention("test").text(" ").mention("test2").text(" ").mention("test3").build(), + builder().mention("test").text(" ").mention("test2").text(" ").mention("test3").text(" ending").build(), + builder().mention("test").text(" ").mention("test2").text(" ").mention("test3").build(), + builder().mention("no").mention("spaces").mention("atall").build(), + + /* Emojis and Spaces */ + builder().mention("test").text(" start 🀘").build(), + builder().mention("test").text(" start 🀘🀘").build(), + builder().mention("test").text(" start πŸ‘πŸΎ").build(), + builder().text("middle 🀑 ").mention("foo").text(" πŸ‘πŸΎ middle").build(), + builder().text("middle πŸ€‘πŸ‘πŸΎ ").mention("test").text(" πŸ‘πŸΎ middle").build(), + builder().text("end end πŸ’€ πŸ’€ πŸ’€ ").mention("bar baz buzz").build(), + builder().text("end end πŸ––πŸΌ πŸ––πŸΌ πŸ––πŸΌ ").mention("really long name").build(), + builder().text("middle πŸ€‘πŸ‘πŸΎ πŸ‘¨πŸΌβ€πŸ€β€πŸ‘¨πŸ½ ").mention("a").text(" πŸ‘πŸΎ middle πŸ‘©β€πŸ‘©β€πŸ‘¦β€πŸ‘¦").build(), + builder().text("start ").mention("emoji 🩳").build(), + builder().text("start ").mention("emoji 🩳").text(" middle ").mention("emoji 🩳").text(" end").build(), + }); + } + + public MentionUtilTest_updateBodyAndMentionsWithPlaceholders(String body, List mentions, String updatedBody, List updatedMentions) { + this.body = body; + this.mentions = mentions; + this.updatedBody = updatedBody; + this.updatedMentions = updatedMentions; + } + + @Test + public void updateBodyAndMentions() { + MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithPlaceholders(body, mentions); + assertEquals(updatedBody, updated.getBodyAsString()); + assertEquals(updatedMentions, updated.getMentions()); + } + + private static Builder builder() { + return new Builder(); + } + + private static class Builder { + private StringBuilder bodyBuilder = new StringBuilder(); + private StringBuilder expectedBuilder = new StringBuilder(); + + private List mentions = new ArrayList<>(); + private List expectedMentions = new ArrayList<>(); + + Builder text(String text) { + bodyBuilder.append(text); + expectedBuilder.append(text); + return this; + } + + Builder mention(String name) { + Mention input = new Mention(RecipientId.from(new Random().nextLong()), bodyBuilder.length(), name.length() + 1); + bodyBuilder.append(MENTION_STARTER).append(name); + mentions.add(input); + + Mention output = new Mention(input.getRecipientId(), expectedBuilder.length(), MENTION_PLACEHOLDER.length()); + expectedBuilder.append(MENTION_PLACEHOLDER); + expectedMentions.add(output); + return this; + } + + Object[] build() { + return new Object[]{ bodyBuilder.toString(), mentions, expectedBuilder.toString(), expectedMentions }; + } + } +} \ 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 b64b6de285..48bd477958 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 @@ -578,11 +578,22 @@ public class SignalServiceMessageSender { .setText(message.getQuote().get().getText()); if (message.getQuote().get().getAuthor().getUuid().isPresent()) { - quoteBuilder = quoteBuilder.setAuthorUuid(message.getQuote().get().getAuthor().getUuid().get().toString()); + quoteBuilder.setAuthorUuid(message.getQuote().get().getAuthor().getUuid().get().toString()); } if (message.getQuote().get().getAuthor().getNumber().isPresent()) { - quoteBuilder = quoteBuilder.setAuthorE164(message.getQuote().get().getAuthor().getNumber().get()); + quoteBuilder.setAuthorE164(message.getQuote().get().getAuthor().getNumber().get()); + } + + if (!message.getQuote().get().getMentions().isEmpty()) { + for (SignalServiceDataMessage.Mention mention : message.getQuote().get().getMentions()) { + quoteBuilder.addBodyRanges(DataMessage.BodyRange.newBuilder() + .setStart(mention.getStart()) + .setLength(mention.getLength()) + .setMentionUuid(mention.getUuid().toString())); + } + + builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.MENTIONS_VALUE, builder.getRequiredProtocolVersion())); } for (SignalServiceDataMessage.Quote.QuotedAttachment attachment : message.getQuote().get().getAttachments()) { @@ -626,6 +637,16 @@ 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.getUuid().toString())); + } + builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.MENTIONS_VALUE, builder.getRequiredProtocolVersion())); + } + if (message.getSticker().isPresent()) { DataMessage.Sticker.Builder stickerBuilder = DataMessage.Sticker.newBuilder(); 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 a750baaa44..9dc0faf59f 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 @@ -347,6 +347,7 @@ public final class SignalServiceContent { SignalServiceDataMessage.Quote quote = createQuote(content); List sharedContacts = createSharedContacts(content); List previews = createPreviews(content); + List mentions = createMentions(content); SignalServiceDataMessage.Sticker sticker = createSticker(content); SignalServiceDataMessage.Reaction reaction = createReaction(content); SignalServiceDataMessage.RemoteDelete remoteDelete = createRemoteDelete(content); @@ -381,6 +382,7 @@ public final class SignalServiceContent { quote, sharedContacts, previews, + mentions, sticker, content.getIsViewOnce(), reaction, @@ -662,7 +664,8 @@ public final class SignalServiceContent { return new SignalServiceDataMessage.Quote(content.getQuote().getId(), address, content.getQuote().getText(), - attachments); + attachments, + createMentions(content)); } else { Log.w(TAG, "Quote was missing an author! Returning null."); return null; @@ -689,6 +692,35 @@ public final class SignalServiceContent { return results; } + private static List createMentions(SignalServiceProtos.DataMessage content) throws ProtocolInvalidMessageException { + if (content.getBodyRangesCount() <= 0 || !content.hasBody()) return null; + + List mentions = new LinkedList<>(); + + for (SignalServiceProtos.DataMessage.BodyRange bodyRange : content.getBodyRangesList()) { + if (bodyRange.hasMentionUuid()) { + try { + validateBodyRange(content, bodyRange); + mentions.add(new SignalServiceDataMessage.Mention(UuidUtil.parseOrThrow(bodyRange.getMentionUuid()), bodyRange.getStart(), bodyRange.getLength())); + } catch (IllegalArgumentException e) { + throw new ProtocolInvalidMessageException(new InvalidMessageException(e), null, 0); + } + } + } + + return mentions; + } + + private static void validateBodyRange(SignalServiceProtos.DataMessage content, SignalServiceProtos.DataMessage.BodyRange bodyRange) throws ProtocolInvalidMessageException { + int incomingBodyLength = content.hasBody() ? content.getBody().length() : -1; + int start = bodyRange.hasStart() ? bodyRange.getStart() : -1; + int length = bodyRange.hasLength() ? bodyRange.getLength() : -1; + + if (start < 0 || length < 0 || (start + length) > incomingBodyLength) { + throw new ProtocolInvalidMessageException(new InvalidMessageException("Incoming body range has out-of-bound range"), null, 0); + } + } + private static SignalServiceDataMessage.Sticker createSticker(SignalServiceProtos.DataMessage content) throws ProtocolInvalidMessageException { if (!content.hasSticker() || !content.getSticker().hasPackId() || diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java index 794f80b169..28f3a3dac3 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceDataMessage.java @@ -14,6 +14,7 @@ import org.whispersystems.signalservice.api.util.OptionalUtil; import java.util.LinkedList; import java.util.List; +import java.util.UUID; /** * Represents a decrypted Signal Service data message. @@ -32,6 +33,7 @@ public class SignalServiceDataMessage { private final Optional quote; private final Optional> contacts; private final Optional> previews; + private final Optional> mentions; private final Optional sticker; private final boolean viewOnce; private final Optional reaction; @@ -54,7 +56,7 @@ public class SignalServiceDataMessage { String body, boolean endSession, int expiresInSeconds, boolean expirationUpdate, byte[] profileKey, boolean profileKeyUpdate, Quote quote, List sharedContacts, List previews, - Sticker sticker, boolean viewOnce, Reaction reaction, RemoteDelete remoteDelete) + List mentions, Sticker sticker, boolean viewOnce, Reaction reaction, RemoteDelete remoteDelete) { try { this.group = SignalServiceGroupContext.createOptional(group, groupV2); @@ -92,6 +94,12 @@ public class SignalServiceDataMessage { } else { this.previews = Optional.absent(); } + + if (mentions != null && !mentions.isEmpty()) { + this.mentions = Optional.of(mentions); + } else { + this.mentions = Optional.absent(); + } } public static Builder newBuilder() { @@ -174,6 +182,10 @@ public class SignalServiceDataMessage { return previews; } + public Optional> getMentions() { + return mentions; + } + public Optional getSticker() { return sticker; } @@ -195,6 +207,7 @@ public class SignalServiceDataMessage { private List attachments = new LinkedList<>(); private List sharedContacts = new LinkedList<>(); private List previews = new LinkedList<>(); + private List mentions = new LinkedList<>(); private long timestamp; private SignalServiceGroup group; @@ -302,6 +315,11 @@ public class SignalServiceDataMessage { return this; } + public Builder withMentions(List mentions) { + this.mentions.addAll(mentions); + return this; + } + public Builder withSticker(Sticker sticker) { this.sticker = sticker; return this; @@ -327,7 +345,7 @@ public class SignalServiceDataMessage { return new SignalServiceDataMessage(timestamp, group, groupV2, attachments, body, endSession, expiresInSeconds, expirationUpdate, profileKey, profileKeyUpdate, quote, sharedContacts, previews, - sticker, viewOnce, reaction, remoteDelete); + mentions, sticker, viewOnce, reaction, remoteDelete); } } @@ -336,12 +354,14 @@ public class SignalServiceDataMessage { private final SignalServiceAddress author; private final String text; private final List attachments; + private final List mentions; - public Quote(long id, SignalServiceAddress author, String text, List attachments) { + public Quote(long id, SignalServiceAddress author, String text, List attachments, List mentions) { this.id = id; this.author = author; this.text = text; this.attachments = attachments; + this.mentions = mentions; } public long getId() { @@ -360,6 +380,10 @@ public class SignalServiceDataMessage { return attachments; } + public List getMentions() { + return mentions; + } + public static class QuotedAttachment { private final String contentType; private final String fileName; @@ -480,4 +504,28 @@ public class SignalServiceDataMessage { return targetSentTimestamp; } } + + public static class Mention { + private final UUID uuid; + private final int start; + private final int length; + + public Mention(UUID uuid, int start, int length) { + this.uuid = uuid; + this.start = start; + this.length = length; + } + + public UUID getUuid() { + return uuid; + } + + public int getStart() { + return start; + } + + public int getLength() { + return length; + } + } } diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index 50a61cca64..3bb08112c9 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -111,6 +111,15 @@ message DataMessage { PROFILE_KEY_UPDATE = 4; } + message BodyRange { + optional int32 start = 1; + optional int32 length = 2; + + oneof associatedValue { + string mentionUuid = 3; + } + } + message Quote { message QuotedAttachment { optional string contentType = 1; @@ -123,6 +132,7 @@ message DataMessage { optional string authorUuid = 5; optional string text = 3; repeated QuotedAttachment attachments = 4; + repeated BodyRange bodyRanges = 6; } message Contact { @@ -226,7 +236,8 @@ message DataMessage { VIEW_ONCE_VIDEO = 3; REACTIONS = 4; CDN_SELECTOR_ATTACHMENTS = 5; - CURRENT = 5; + MENTIONS = 6; + CURRENT = 6; } optional string body = 1; @@ -245,6 +256,7 @@ message DataMessage { optional bool isViewOnce = 14; optional Reaction reaction = 16; optional Delete delete = 17; + repeated BodyRange bodyRanges = 18; } message NullMessage {