Add mentions for v2 group chats.

This commit is contained in:
Cody Henthorne 2020-08-05 16:45:52 -04:00 committed by Greyson Parrelli
parent 0bb9c1d650
commit b2d4c5d14b
90 changed files with 2279 additions and 372 deletions

View File

@ -47,7 +47,7 @@ public interface BindableConversationItem extends Unbindable {
void onMessageSharedContactClicked(@NonNull List<Recipient> choices); void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
void onInviteSharedContactClicked(@NonNull List<Recipient> choices); void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms); 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); void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
} }
} }

View File

@ -5,6 +5,7 @@ import android.content.res.Configuration;
import android.graphics.Canvas; import android.graphics.Canvas;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.text.Annotation;
import android.text.Editable; import android.text.Editable;
import android.text.InputType; import android.text.InputType;
import android.text.Spannable; import android.text.Spannable;
@ -13,7 +14,6 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.TextUtils.TruncateAt; import android.text.TextUtils.TruncateAt;
import android.text.method.QwertyKeyListener;
import android.text.style.RelativeSizeSpan; import android.text.style.RelativeSizeSpan;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
@ -21,7 +21,6 @@ import android.view.inputmethod.InputConnection;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.os.BuildCompat;
import androidx.core.view.inputmethod.EditorInfoCompat; import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat; import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat; import androidx.core.view.inputmethod.InputContentInfoCompat;
@ -30,18 +29,26 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText; import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation; 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.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.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences; 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 { public class ComposeText extends EmojiEditText {
private CharSequence hint; private CharSequence combinedHint;
private SpannableString subHint;
private MentionRendererDelegate mentionRendererDelegate; private MentionRendererDelegate mentionRendererDelegate;
private MentionValidatorWatcher mentionValidatorWatcher;
@Nullable private InputPanel.MediaListener mediaListener; @Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener; @Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@ -62,47 +69,63 @@ public class ComposeText extends EmojiEditText {
initialize(); 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 @Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) { protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom); super.onLayout(changed, left, top, right, bottom);
if (!TextUtils.isEmpty(hint)) { if (!TextUtils.isEmpty(combinedHint)) {
if (!TextUtils.isEmpty(subHint)) { setHint(combinedHint);
setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
.append("\n")
.append(ellipsizeToWidth(subHint)));
} else {
setHint(ellipsizeToWidth(hint));
}
} }
} }
@Override @Override
protected void onSelectionChanged(int selStart, int selEnd) { protected void onSelectionChanged(int selectionStart, int selectionEnd) {
super.onSelectionChanged(selStart, selEnd); super.onSelectionChanged(selectionStart, selectionEnd);
if (FeatureFlags.mentions()) { if (FeatureFlags.mentions() && getText() != null) {
if (selStart == selEnd) { boolean selectionChanged = changeSelectionForPartialMentions(getText(), selectionStart, selectionEnd);
doAfterCursorChange(); if (selectionChanged) {
return;
}
if (selectionStart == selectionEnd) {
doAfterCursorChange(getText());
} else { } else {
updateQuery(""); updateQuery("");
} }
} }
if (cursorPositionChangedListener != null) { if (cursorPositionChangedListener != null) {
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd); cursorPositionChangedListener.onCursorPositionChanged(selectionStart, selectionEnd);
} }
} }
@Override @Override
protected void onDraw(Canvas canvas) { protected void onDraw(Canvas canvas) {
if (FeatureFlags.mentions() && getText() != null && getLayout() != null) { if (getText() != null && getLayout() != null) {
int checkpoint = canvas.save(); 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()); canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
try { try {
mentionRendererDelegate.draw(canvas, getText(), getLayout()); mentionRendererDelegate.draw(canvas, getText(), getLayout());
} finally { } finally {
@ -120,25 +143,25 @@ public class ComposeText extends EmojiEditText {
} }
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) { public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
this.hint = hint;
if (subHint != null) { if (subHint != null) {
this.subHint = new SpannableString(subHint); Spannable subHintSpannable = new SpannableString(subHint);
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE); 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 { } else {
this.subHint = null; combinedHint = ellipsizeToWidth(hint);
} }
if (this.subHint != null) { super.setHint(combinedHint);
super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
.append("\n")
.append(ellipsizeToWidth(this.subHint)));
} else {
super.setHint(ellipsizeToWidth(this.hint));
}
} }
public void appendInvite(String invite) { public void appendInvite(String invite) {
if (getText() == null) {
return;
}
if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) { if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) {
append(" "); append(" ");
} }
@ -155,13 +178,18 @@ public class ComposeText extends EmojiEditText {
this.mentionQueryChangedListener = listener; this.mentionQueryChangedListener = listener;
} }
public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) {
if (FeatureFlags.mentions()) {
mentionValidatorWatcher.setMentionValidator(mentionValidator);
}
}
private boolean isLandscape() { private boolean isLandscape() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
} }
public void setTransport(TransportOption transport) { public void setTransport(TransportOption transport) {
final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext()); final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext());
final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext());
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND; int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
int inputType = getInputType(); int inputType = getInputType();
@ -201,19 +229,59 @@ public class ComposeText extends EmojiEditText {
this.mediaListener = mediaListener; this.mediaListener = mediaListener;
} }
public boolean hasMentions() {
Editable text = getText();
if (text != null) {
return !MentionAnnotation.getMentionAnnotations(text).isEmpty();
}
return false;
}
public @NonNull List<Mention> getMentions() {
return MentionAnnotation.getMentionsFromAnnotations(getText());
}
private void initialize() { private void initialize() {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) { if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216); setImeOptions(getImeOptions() | 16777216);
} }
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ThemeUtil.getThemedColor(getContext(), R.attr.conversation_mention_background_color));
if (FeatureFlags.mentions()) { if (FeatureFlags.mentions()) {
mentionRendererDelegate = new MentionRendererDelegate(getContext()); addTextChangedListener(new MentionDeleter());
mentionValidatorWatcher = new MentionValidatorWatcher();
addTextChangedListener(mentionValidatorWatcher);
} }
} }
private void doAfterCursorChange() { private boolean changeSelectionForPartialMentions(@NonNull Spanned spanned, int selectionStart, int selectionEnd) {
Editable text = getText(); Annotation[] annotations = spanned.getSpans(0, spanned.length(), Annotation.class);
if (text != null && enoughToFilter(text)) { 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); performFiltering(text);
} else { } else {
updateQuery(""); updateQuery("");
@ -241,7 +309,7 @@ public class ComposeText extends EmojiEditText {
return end - findQueryStart(text, end) >= 1; 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(); Editable text = getText();
if (text == null) { if (text == null) {
return; return;
@ -251,14 +319,12 @@ public class ComposeText extends EmojiEditText {
int end = getSelectionEnd(); int end = getSelectionEnd();
int start = findQueryStart(text, end) - 1; 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, recipientId));
text.replace(start, end, createReplacementToken(displayName, uuid));
} }
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull UUID uuid) { private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) {
SpannableStringBuilder builder = new SpannableStringBuilder("@"); SpannableStringBuilder builder = new SpannableStringBuilder().append(MENTION_STARTER);
if (text instanceof Spanned) { if (text instanceof Spanned) {
SpannableString spannableString = new SpannableString(text + " "); SpannableString spannableString = new SpannableString(text + " ");
TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0); 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.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; return builder;
} }
@ -278,11 +344,11 @@ public class ComposeText extends EmojiEditText {
} }
int delimiterSearchIndex = inputCursorPosition - 1; 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--; delimiterSearchIndex--;
} }
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == '@') { if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == MENTION_STARTER) {
return delimiterSearchIndex + 1; return delimiterSearchIndex + 1;
} }
return inputCursorPosition; return inputCursorPosition;
@ -300,7 +366,7 @@ public class ComposeText extends EmojiEditText {
@Override @Override
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) { 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 { try {
inputContentInfo.requestPermission(); inputContentInfo.requestPermission();
} catch (Exception e) { } catch (Exception e) {

View File

@ -2,10 +2,8 @@ package org.thoughtcrime.securesms.components;
import android.animation.Animator; import android.animation.Animator;
import android.animation.ValueAnimator; import android.animation.ValueAnimator;
import android.annotation.TargetApi;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.Build;
import android.text.format.DateUtils; import android.text.format.DateUtils;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.view.KeyEvent; import android.view.KeyEvent;
@ -94,7 +92,6 @@ public class InputPanel extends LinearLayout
super(context, attrs); super(context, attrs);
} }
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) { public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr); super(context, attrs, defStyleAttr);
} }
@ -160,7 +157,7 @@ public class InputPanel extends LinearLayout
public void setQuote(@NonNull GlideRequests glideRequests, public void setQuote(@NonNull GlideRequests glideRequests,
long id, long id,
@NonNull Recipient author, @NonNull Recipient author,
@NonNull String body, @NonNull CharSequence body,
@NonNull SlideDeck attachments) @NonNull SlideDeck attachments)
{ {
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments); this.quoteView.setQuote(glideRequests, id, author, body, false, attachments);
@ -228,7 +225,7 @@ public class InputPanel extends LinearLayout
public Optional<QuoteModel> getQuote() { public Optional<QuoteModel> getQuote() {
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) { 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 { } else {
return Optional.absent(); return Optional.absent();
} }

View File

@ -23,6 +23,8 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment; 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.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.Slide;
@ -55,7 +57,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private long id; private long id;
private LiveRecipient author; private LiveRecipient author;
private String body; private CharSequence body;
private TextView mediaDescriptionText; private TextView mediaDescriptionText;
private TextView missingLinkText; private TextView missingLinkText;
private SlideDeck attachments; private SlideDeck attachments;
@ -147,7 +149,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
public void setQuote(GlideRequests glideRequests, public void setQuote(GlideRequests glideRequests,
long id, long id,
@NonNull Recipient author, @NonNull Recipient author,
@Nullable String body, @Nullable CharSequence body,
boolean originalMissing, boolean originalMissing,
@NonNull SlideDeck attachments) @NonNull SlideDeck attachments)
{ {
@ -196,7 +198,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing)); 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()) { if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
bodyView.setVisibility(VISIBLE); bodyView.setVisibility(VISIBLE);
bodyView.setText(body == null ? "" : body); bodyView.setText(body == null ? "" : body);
@ -280,11 +282,15 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
return author.get(); return author.get();
} }
public String getBody() { public CharSequence getBody() {
return body; return body;
} }
public List<Attachment> getAttachments() { public List<Attachment> getAttachments() {
return attachments.asAttachments(); return attachments.asAttachments();
} }
public @NonNull List<Mention> getMentions() {
return MentionAnnotation.getMentionsFromAnnotations(body);
}
} }

View File

@ -2,12 +2,17 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context; import android.content.Context;
import android.content.res.TypedArray; import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.widget.TextViewCompat; import androidx.core.widget.TextViewCompat;
import androidx.appcompat.widget.AppCompatTextView; import androidx.appcompat.widget.AppCompatTextView;
import android.text.Annotation;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.AttributeSet; import android.util.AttributeSet;
import android.util.TypedValue; import android.util.TypedValue;
@ -15,10 +20,15 @@ import android.util.TypedValue;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable; import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser; 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.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
public class EmojiTextView extends AppCompatTextView { public class EmojiTextView extends AppCompatTextView {
@ -35,6 +45,9 @@ public class EmojiTextView extends AppCompatTextView {
private int maxLength; private int maxLength;
private CharSequence overflowText; private CharSequence overflowText;
private CharSequence previousOverflowText; private CharSequence previousOverflowText;
private boolean renderMentions;
private MentionRendererDelegate mentionRendererDelegate;
public EmojiTextView(Context context) { public EmojiTextView(Context context) {
this(context, null); this(context, null);
@ -48,14 +61,33 @@ public class EmojiTextView extends AppCompatTextView {
super(context, attrs, defStyleAttr); super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0); TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false); scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1); maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false); forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
a.recycle(); a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize}); a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
originalFontSize = a.getDimensionPixelSize(0, 0); originalFontSize = a.getDimensionPixelSize(0, 0);
a.recycle(); 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) { @Override public void setText(@Nullable CharSequence text, BufferType type) {
@ -115,7 +147,19 @@ public class EmojiTextView extends AppCompatTextView {
private void ellipsizeAnyTextForMaxLength() { private void ellipsizeAnyTextForMaxLength() {
if (maxLength > 0 && getText().length() > maxLength + 1) { if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder(); 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<Annotation> 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); EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);

View File

@ -2,16 +2,26 @@ package org.thoughtcrime.securesms.components.mention;
import android.text.Annotation; import android.text.Annotation;
import android.text.Spannable;
import android.text.Spanned;
import androidx.annotation.NonNull; 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 * Note: Do not extend Annotation or the parceling behavior will be lost.
* span parceling for copy/paste. Do not extend Annotation or this will be lost.
*/ */
public final class MentionAnnotation { public final class MentionAnnotation {
@ -20,7 +30,45 @@ public final class MentionAnnotation {
private MentionAnnotation() { private MentionAnnotation() {
} }
public static Annotation mentionAnnotationForUuid(@NonNull UUID uuid) { public static Annotation mentionAnnotationForRecipientId(@NonNull RecipientId id) {
return new Annotation(MENTION_ANNOTATION, uuid.toString()); 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<Mention> 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<Mention> 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<Annotation> getMentionAnnotations(@NonNull Spanned spanned) {
return getMentionAnnotations(spanned, 0, spanned.length());
}
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned, int start, int end) {
return Stream.of(spanned.getSpans(start, end, Annotation.class))
.filter(MentionAnnotation::isMentionAnnotation)
.toList();
} }
} }

View File

@ -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) { }
}

View File

@ -9,11 +9,11 @@ import android.text.Spanned;
import androidx.annotation.ColorInt; import androidx.annotation.ColorInt;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Px;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.DrawableUtil; import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.ViewUtil;
/** /**
@ -28,42 +28,32 @@ public class MentionRendererDelegate {
private final MentionRenderer multi; private final MentionRenderer multi;
private final int horizontalPadding; private final int horizontalPadding;
public MentionRendererDelegate(@NonNull Context context) { public MentionRendererDelegate(@NonNull Context context, @ColorInt int tint) {
//noinspection ConstantConditions this.horizontalPadding = ViewUtil.dpToPx(2);
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(int horizontalPadding, Drawable drawable = ContextCompat.getDrawable(context, R.drawable.mention_text_bg);
int verticalPadding, Drawable drawableLeft = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left);
@NonNull Drawable drawable, Drawable drawableMid = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid);
@NonNull Drawable drawableLeft, Drawable drawableEnd = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right);
@NonNull Drawable drawableMid,
@NonNull Drawable drawableEnd, //noinspection ConstantConditions
@ColorInt int tint) single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding,
{ 0,
this.horizontalPadding = horizontalPadding; DrawableUtil.tint(drawable, tint));
single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding, //noinspection ConstantConditions
verticalPadding, multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding,
DrawableUtil.tint(drawable, tint)); 0,
multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding, DrawableUtil.tint(drawableLeft, tint),
verticalPadding, DrawableUtil.tint(drawableMid, tint),
DrawableUtil.tint(drawableLeft, tint), DrawableUtil.tint(drawableEnd, tint));
DrawableUtil.tint(drawableMid, tint),
DrawableUtil.tint(drawableEnd, tint));
} }
public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) { public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) {
Annotation[] spans = text.getSpans(0, text.length(), Annotation.class); Annotation[] annotations = text.getSpans(0, text.length(), Annotation.class);
for (Annotation span : spans) { for (Annotation annotation : annotations) {
if (MentionAnnotation.MENTION_ANNOTATION.equals(span.getKey())) { if (MentionAnnotation.isMentionAnnotation(annotation)) {
int spanStart = text.getSpanStart(span); int spanStart = text.getSpanStart(annotation);
int spanEnd = text.getSpanEnd(span); int spanEnd = text.getSpanEnd(annotation);
int startLine = layout.getLineForOffset(spanStart); int startLine = layout.getLineForOffset(spanStart);
int endLine = layout.getLineForOffset(spanEnd); int endLine = layout.getLineForOffset(spanEnd);

View File

@ -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<Annotation> 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<Annotation> 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<Annotation> 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<Annotation> getInvalidMentionAnnotations(List<Annotation> mentionAnnotations);
}
}

View File

@ -41,6 +41,8 @@ import android.provider.Browser;
import android.provider.ContactsContract; import android.provider.ContactsContract;
import android.provider.Telephony; import android.provider.Telephony;
import android.text.Editable; import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextWatcher; import android.text.TextWatcher;
import android.view.Gravity; import android.view.Gravity;
import android.view.KeyEvent; import android.view.KeyEvent;
@ -72,6 +74,7 @@ import androidx.core.content.pm.ShortcutManagerCompat;
import androidx.core.graphics.drawable.IconCompat; import androidx.core.graphics.drawable.IconCompat;
import androidx.lifecycle.ViewModelProviders; import androidx.lifecycle.ViewModelProviders;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import com.bumptech.glide.load.engine.DiskCacheStrategy; import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.request.target.CustomTarget; 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.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView; import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
import org.thoughtcrime.securesms.components.location.SignalPlace; 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.ExpiredBuildReminder;
import org.thoughtcrime.securesms.components.reminder.Reminder; import org.thoughtcrime.securesms.components.reminder.Reminder;
import org.thoughtcrime.securesms.components.reminder.ReminderView; 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.ContactUtil;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState; 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.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult; 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;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord; import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; 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.MmsSmsColumns.Types;
import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.identity.IdentityRecordList; 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.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord; 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.SlideDeck;
import org.thoughtcrime.securesms.mms.StickerSlide; import org.thoughtcrime.securesms.mms.StickerSlide;
import org.thoughtcrime.securesms.mms.VideoSlide; import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.GroupShareProfileView; 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.StickerManagementActivity;
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent; import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
import org.thoughtcrime.securesms.stickers.StickerSearchRepository; import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.CommunicationActions;
@ -248,10 +255,12 @@ import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional; import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
@ -648,6 +657,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
boolean initiating = threadId == -1; boolean initiating = threadId == -1;
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull(); QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull();
SlideDeck slideDeck = new SlideDeck(); SlideDeck slideDeck = new SlideDeck();
List<Mention> mentions = new ArrayList<>(result.getMentions());
for (Media mediaItem : result.getNonUploadedMedia()) { for (Media mediaItem : result.getNonUploadedMedia()) {
if (MediaUtil.isVideoType(mediaItem.getMimeType())) { if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
@ -669,6 +679,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
quote, quote,
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
mentions,
expiresIn, expiresIn,
result.isViewOnce(), result.isViewOnce(),
subscriptionId, subscriptionId,
@ -1373,7 +1384,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private ListenableFuture<Boolean> initializeDraft() { private ListenableFuture<Boolean> initializeDraft() {
final SettableFuture<Boolean> result = new SettableFuture<>(); final SettableFuture<Boolean> result = new SettableFuture<>();
final String draftText = getIntent().getStringExtra(TEXT_EXTRA); final CharSequence draftText = getIntent().getCharSequenceExtra(TEXT_EXTRA);
final Uri draftMedia = getIntent().getData(); final Uri draftMedia = getIntent().getData();
final String draftContentType = getIntent().getType(); final String draftContentType = getIntent().getType();
final MediaType draftMediaType = MediaType.from(draftContentType); final MediaType draftMediaType = MediaType.from(draftContentType);
@ -1437,19 +1448,34 @@ public class ConversationActivity extends PassphraseRequiredActivity
private ListenableFuture<Boolean> initializeDraftFromDatabase() { private ListenableFuture<Boolean> initializeDraftFromDatabase() {
SettableFuture<Boolean> future = new SettableFuture<>(); SettableFuture<Boolean> future = new SettableFuture<>();
new AsyncTask<Void, Void, List<Draft>>() { new AsyncTask<Void, Void, Pair<Drafts, CharSequence>>() {
@Override @Override
protected List<Draft> doInBackground(Void... params) { protected Pair<Drafts, CharSequence> doInBackground(Void... params) {
DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this); Context context = ConversationActivity.this;
List<Draft> results = draftDatabase.getDrafts(threadId); 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<Mention> 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); draftDatabase.clearDrafts(threadId);
return results; return new Pair<>(results, updatedText);
} }
@Override @Override
protected void onPostExecute(List<Draft> drafts) { protected void onPostExecute(Pair<Drafts, CharSequence> draftsWithUpdatedMentions) {
Drafts drafts = Objects.requireNonNull(draftsWithUpdatedMentions.first());
CharSequence updatedText = draftsWithUpdatedMentions.second();
if (drafts.isEmpty()) { if (drafts.isEmpty()) {
future.set(false); future.set(false);
updateToggleButtonState(); updateToggleButtonState();
@ -1473,7 +1499,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
try { try {
switch (draft.getType()) { switch (draft.getType()) {
case Draft.TEXT: case Draft.TEXT:
composeText.setText(draft.getValue()); composeText.setText(updatedText == null ? draft.getValue() : updatedText);
listener.onSuccess(true); listener.onSuccess(true);
break; break;
case Draft.LOCATION: case Draft.LOCATION:
@ -1874,8 +1900,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
MentionsPickerViewModel mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class); MentionsPickerViewModel mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class);
recipient.observe(this, mentionsViewModel::onRecipientChange); recipient.observe(this, mentionsViewModel::onRecipientChange);
composeText.setMentionQueryChangedListener(query -> { composeText.setMentionQueryChangedListener(query -> {
if (getRecipient().isGroup()) { if (getRecipient().isPushV2Group()) {
if (!mentionsSuggestions.resolved()) { if (!mentionsSuggestions.resolved()) {
mentionsSuggestions.get(); mentionsSuggestions.get();
} }
@ -1883,12 +1910,26 @@ public class ConversationActivity extends PassphraseRequiredActivity
} }
}); });
composeText.setMentionValidator(annotations -> {
if (!getRecipient().isPushV2Group()) {
return annotations;
}
Set<String> 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 -> { mentionsViewModel.getSelectedRecipient().observe(this, recipient -> {
String replacementDisplayName = recipient.getDisplayName(this); String replacementDisplayName = recipient.getDisplayName(this);
if (replacementDisplayName.equals(recipient.getDisplayUsername())) { if (replacementDisplayName.equals(recipient.getDisplayUsername())) {
replacementDisplayName = recipient.getUsername().or(replacementDisplayName); 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; long expiresIn = recipient.get().getExpireMessages() * 1000L;
boolean initiating = threadId == -1; 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) { private void selectContactInfo(ContactData contactData) {
@ -2097,7 +2138,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
Drafts drafts = new Drafts(); Drafts drafts = new Drafts();
if (!Util.isEmpty(composeText)) { if (!Util.isEmpty(composeText)) {
drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed())); drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed().toString()));
List<Mention> draftMentions = composeText.getMentions();
if (!draftMentions.isEmpty()) {
drafts.add(new Draft(Draft.MENTION, Base64.encodeBytes(MentionUtil.mentionsToBodyRangeList(draftMentions).toByteArray())));
}
} }
for (Slide slide : attachmentManager.buildSlideDeck().getSlides()) { for (Slide slide : attachmentManager.buildSlideDeck().getSlides()) {
@ -2187,7 +2232,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
} }
private void calculateCharactersRemaining() { private void calculateCharactersRemaining() {
String messageBody = composeText.getTextTrimmed(); String messageBody = composeText.getTextTrimmed().toString();
TransportOption transportOption = sendButton.getSelectedTransport(); TransportOption transportOption = sendButton.getSelectedTransport();
CharacterState characterState = transportOption.calculateCharacters(messageBody); CharacterState characterState = transportOption.calculateCharacters(messageBody);
@ -2270,7 +2315,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
} }
private String getMessage() throws InvalidMessageException { private String getMessage() throws InvalidMessageException {
String rawText = composeText.getTextTrimmed(); String rawText = composeText.getTextTrimmed().toString();
if (rawText.length() < 1 && !attachmentManager.isAttachmentPresent()) if (rawText.length() < 1 && !attachmentManager.isAttachmentPresent())
throw new InvalidMessageException(getString(R.string.ConversationActivity_message_is_empty_exclamation)); throw new InvalidMessageException(getString(R.string.ConversationActivity_message_is_empty_exclamation));
@ -2339,6 +2384,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
recipient.isGroup() || recipient.isGroup() ||
recipient.getEmail().isPresent() || recipient.getEmail().isPresent() ||
inputPanel.getQuote().isPresent() || inputPanel.getQuote().isPresent() ||
composeText.hasMentions() ||
linkPreviewViewModel.hasLinkPreview() || linkPreviewViewModel.hasLinkPreview() ||
needsSplit; needsSplit;
@ -2369,9 +2415,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
private void sendMediaMessage(@NonNull MediaSendActivityResult result) { private void sendMediaMessage(@NonNull MediaSendActivityResult result) {
long expiresIn = recipient.get().getExpireMessages() * 1000L; long expiresIn = recipient.get().getExpireMessages() * 1000L;
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull(); QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull();
List<Mention> mentions = new ArrayList<>(result.getMentions());
boolean initiating = threadId == -1; 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 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 ); OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message);
ApplicationContext.getInstance(this).getTypingStatusSender().onTypingStopped(threadId); ApplicationContext.getInstance(this).getTypingStatusSender().onTypingStopped(threadId);
@ -2395,7 +2442,18 @@ public class ConversationActivity extends PassphraseRequiredActivity
throws InvalidMessageException throws InvalidMessageException
{ {
Log.i(TAG, "Sending media message..."); 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<Void> sendMediaMessage(final boolean forceSms, private ListenableFuture<Void> sendMediaMessage(final boolean forceSms,
@ -2404,6 +2462,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
QuoteModel quote, QuoteModel quote,
List<Contact> contacts, List<Contact> contacts,
List<LinkPreview> previews, List<LinkPreview> previews,
List<Mention> mentions,
final long expiresIn, final long expiresIn,
final boolean viewOnce, final boolean viewOnce,
final int subscriptionId, 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<Void> future = new SettableFuture<>(); final SettableFuture<Void> future = new SettableFuture<>();
final Context context = getApplicationContext(); final Context context = getApplicationContext();
@ -2543,7 +2602,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private void updateLinkPreviewState() { private void updateLinkPreviewState() {
if (TextSecurePreferences.isLinkPreviewsEnabled(this) && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) { if (TextSecurePreferences.isLinkPreviewsEnabled(this) && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) {
linkPreviewViewModel.onEnabled(); linkPreviewViewModel.onEnabled();
linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed(), composeText.getSelectionStart(), composeText.getSelectionEnd()); linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed().toString(), composeText.getSelectionStart(), composeText.getSelectionEnd());
} else { } else {
linkPreviewViewModel.onUserCancel(); linkPreviewViewModel.onUserCancel();
} }
@ -2611,7 +2670,20 @@ public class ConversationActivity extends PassphraseRequiredActivity
SlideDeck slideDeck = new SlideDeck(); SlideDeck slideDeck = new SlideDeck();
slideDeck.addSlide(audioSlide); slideDeck.addSlide(audioSlide);
sendMediaMessage(forceSms, "", slideDeck, inputPanel.getQuote().orNull(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, true).addListener(new AssertedSuccessListener<Void>() { ListenableFuture<Void> sendResult = sendMediaMessage(forceSms,
"",
slideDeck,
inputPanel.getQuote().orNull(),
Collections.emptyList(),
Collections.emptyList(),
composeText.getMentions(),
expiresIn,
false,
subscriptionId,
initiating,
true);
sendResult.addListener(new AssertedSuccessListener<Void>() {
@Override @Override
public void onSuccess(Void nothing) { public void onSuccess(Void nothing) {
new AsyncTask<Void, Void, Void>() { new AsyncTask<Void, Void, Void>() {
@ -2700,7 +2772,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
@Override @Override
public void onCursorPositionChanged(int start, int end) { public void onCursorPositionChanged(int start, int end) {
linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed(), start, end); linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed().toString(), start, end);
} }
@Override @Override
@ -2740,7 +2812,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
slideDeck.addSlide(stickerSlide); 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) { private void silentlySetComposeText(String text) {
@ -2969,7 +3041,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
} }
@Override @Override
public void handleReplyMessage(MessageRecord messageRecord) { public void handleReplyMessage(ConversationMessage conversationMessage) {
MessageRecord messageRecord = conversationMessage.getMessageRecord();
Recipient author; Recipient author;
if (messageRecord.isOutgoing()) { if (messageRecord.isOutgoing()) {
@ -3005,7 +3079,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
inputPanel.setQuote(GlideApp.with(this), inputPanel.setQuote(GlideApp.with(this),
messageRecord.getDateSent(), messageRecord.getDateSent(),
author, author,
messageRecord.getBody(), conversationMessage.getDisplayBody(this),
slideDeck); slideDeck);
} else { } else {
SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck(); SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck();
@ -3019,7 +3093,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
inputPanel.setQuote(GlideApp.with(this), inputPanel.setQuote(GlideApp.with(this),
messageRecord.getDateSent(), messageRecord.getDateSent(),
author, author,
messageRecord.getBody(), conversationMessage.getDisplayBody(this),
slideDeck); slideDeck);
} }
@ -3186,6 +3260,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
quote, quote,
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
composeText.getMentions(),
expiresIn, expiresIn,
false, false,
subscriptionId, subscriptionId,
@ -3244,7 +3319,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
} }
} }
private class QuoteRestorationTask extends AsyncTask<Void, Void, MessageRecord> { private class QuoteRestorationTask extends AsyncTask<Void, Void, ConversationMessage> {
private final String serialized; private final String serialized;
private final SettableFuture<Boolean> future; private final SettableFuture<Boolean> future;
@ -3255,20 +3330,27 @@ public class ConversationActivity extends PassphraseRequiredActivity
} }
@Override @Override
protected MessageRecord doInBackground(Void... voids) { protected ConversationMessage doInBackground(Void... voids) {
QuoteId quoteId = QuoteId.deserialize(ConversationActivity.this, serialized); QuoteId quoteId = QuoteId.deserialize(ConversationActivity.this, serialized);
if (quoteId != null) { if (quoteId == null) {
return DatabaseFactory.getMmsSmsDatabase(getApplicationContext()).getMessageFor(quoteId.getId(), quoteId.getAuthor()); 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 @Override
protected void onPostExecute(MessageRecord messageRecord) { protected void onPostExecute(ConversationMessage conversationMessage) {
if (messageRecord != null) { if (conversationMessage != null) {
handleReplyMessage(messageRecord); handleReplyMessage(conversationMessage);
future.set(true); future.set(true);
} else { } else {
Log.e(TAG, "Failed to restore a quote from a draft. No matching message record."); Log.e(TAG, "Failed to restore a quote from a draft. No matching message record.");

View File

@ -4,14 +4,17 @@ import android.content.Context;
import android.database.ContentObserver; import android.database.ContentObserver;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.paging.DataSource; import androidx.paging.DataSource;
import androidx.paging.PositionalDataSource; import androidx.paging.PositionalDataSource;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.database.DatabaseContentProviders; import org.thoughtcrime.securesms.database.DatabaseContentProviders;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; 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 org.thoughtcrime.securesms.util.paging.SizeFixResult;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
/** /**
@ -66,19 +73,24 @@ class ConversationDataSource extends PositionalDataSource<ConversationMessage> {
int totalCount = db.getConversationCount(threadId); int totalCount = db.getConversationCount(threadId);
int effectiveCount = params.requestedStartPosition; int effectiveCount = params.requestedStartPosition;
MentionHelper mentionHelper = new MentionHelper();
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.requestedStartPosition, params.requestedLoadSize))) { try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.requestedStartPosition, params.requestedLoadSize))) {
MessageRecord record; MessageRecord record;
while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) { while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) {
records.add(record); records.add(record);
mentionHelper.add(record);
effectiveCount++; effectiveCount++;
} }
} }
mentionHelper.fetchMentions(context);
if (!isInvalid()) { if (!isInvalid()) {
SizeFixResult<MessageRecord> result = SizeFixResult.ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount); SizeFixResult<MessageRecord> result = SizeFixResult.ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount);
List<ConversationMessage> items = Stream.of(result.getItems()) List<ConversationMessage> items = Stream.of(result.getItems())
.map(ConversationMessage::new) .map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId())))
.toList(); .toList();
callback.onResult(items, params.requestedStartPosition, result.getTotal()); callback.onResult(items, params.requestedStartPosition, result.getTotal());
@ -92,24 +104,48 @@ class ConversationDataSource extends PositionalDataSource<ConversationMessage> {
public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<ConversationMessage> callback) { public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<ConversationMessage> callback) {
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context); MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
List<MessageRecord> records = new ArrayList<>(params.loadSize); List<MessageRecord> records = new ArrayList<>(params.loadSize);
MentionHelper mentionHelper = new MentionHelper();
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.startPosition, params.loadSize))) { try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.startPosition, params.loadSize))) {
MessageRecord record; MessageRecord record;
while ((record = reader.getNext()) != null && !isInvalid()) { while ((record = reader.getNext()) != null && !isInvalid()) {
records.add(record); records.add(record);
mentionHelper.add(record);
} }
} }
mentionHelper.fetchMentions(context);
List<ConversationMessage> items = Stream.of(records) List<ConversationMessage> items = Stream.of(records)
.map(ConversationMessage::new) .map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId())))
.toList(); .toList();
callback.onResult(items); callback.onResult(items);
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.startPosition + ", size: " + params.loadSize + (isInvalid() ? " -- invalidated" : "")); Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.startPosition + ", size: " + params.loadSize + (isInvalid() ? " -- invalidated" : ""));
} }
private static class MentionHelper {
private Collection<Long> messageIds = new LinkedList<>();
private Map<Long, List<Mention>> 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<Mention> getMentions(long id) {
return messageIdToMentions.get(id);
}
}
static class Factory extends DataSource.Factory<Integer, ConversationMessage> { static class Factory extends DataSource.Factory<Integer, ConversationMessage> {
private final Context context; private final Context context;

View File

@ -18,6 +18,8 @@ package org.thoughtcrime.securesms.conversation;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
@ -25,7 +27,7 @@ import android.net.Uri;
import android.os.AsyncTask; import android.os.AsyncTask;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.text.ClipboardManager; import android.text.SpannableStringBuilder;
import android.text.TextUtils; import android.text.TextUtils;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
@ -72,6 +74,7 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity; import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener; import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener;
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder; 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.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase; import org.thoughtcrime.securesms.database.MessagingDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase;
@ -128,7 +131,6 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@ -595,33 +597,25 @@ public class ConversationFragment extends LoggingFragment {
} }
private void handleCopyMessage(final Set<ConversationMessage> conversationMessages) { private void handleCopyMessage(final Set<ConversationMessage> conversationMessages) {
List<MessageRecord> messageList = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).toList(); List<ConversationMessage> messageList = new ArrayList<>(conversationMessages);
Collections.sort(messageList, new Comparator<MessageRecord>() { Collections.sort(messageList, (lhs, rhs) -> Long.compare(lhs.getMessageRecord().getDateReceived(), rhs.getMessageRecord().getDateReceived()));
@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;
}
});
StringBuilder bodyBuilder = new StringBuilder(); SpannableStringBuilder bodyBuilder = new SpannableStringBuilder();
ClipboardManager clipboard = (ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); ClipboardManager clipboard = (ClipboardManager) requireActivity().getSystemService(Context.CLIPBOARD_SERVICE);
for (MessageRecord messageRecord : messageList) { for (ConversationMessage message : messageList) {
String body = messageRecord.getDisplayBody(requireContext()).toString(); CharSequence body = message.getDisplayBody(requireContext());
if (!TextUtils.isEmpty(body)) { 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<ConversationMessage> conversationMessages) { private void handleDeleteMessages(final Set<ConversationMessage> conversationMessages) {
@ -746,8 +740,7 @@ public class ConversationFragment extends LoggingFragment {
} }
private void handleForwardMessage(ConversationMessage conversationMessage) { private void handleForwardMessage(ConversationMessage conversationMessage) {
MessageRecord message = conversationMessage.getMessageRecord(); if (conversationMessage.getMessageRecord().isViewOnce()) {
if (message.isViewOnce()) {
throw new AssertionError("Cannot forward a view-once message."); throw new AssertionError("Cannot forward a view-once message.");
} }
@ -755,10 +748,10 @@ public class ConversationFragment extends LoggingFragment {
SimpleTask.run(getLifecycle(), () -> { SimpleTask.run(getLifecycle(), () -> {
Intent composeIntent = new Intent(getActivity(), ShareActivity.class); 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()) { if (conversationMessage.getMessageRecord().isMms()) {
MmsMessageRecord mediaMessage = (MmsMessageRecord) message; MmsMessageRecord mediaMessage = (MmsMessageRecord) conversationMessage.getMessageRecord();
boolean isAlbum = mediaMessage.containsMediaSlide() && boolean isAlbum = mediaMessage.containsMediaSlide() &&
mediaMessage.getSlideDeck().getSlides().size() > 1 && mediaMessage.getSlideDeck().getSlides().size() > 1 &&
mediaMessage.getSlideDeck().getAudioSlide() == null && mediaMessage.getSlideDeck().getAudioSlide() == null &&
@ -788,7 +781,7 @@ public class ConversationFragment extends LoggingFragment {
Optional.fromNullable(attachment.getCaption()), Optional.fromNullable(attachment.getCaption()),
Optional.absent())); Optional.absent()));
} }
}; }
if (!mediaList.isEmpty()) { if (!mediaList.isEmpty()) {
composeIntent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaList); composeIntent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaList);
@ -835,7 +828,7 @@ public class ConversationFragment extends LoggingFragment {
((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView(); ((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView();
} }
listener.handleReplyMessage(message.getMessageRecord()); listener.handleReplyMessage(message);
} }
private void handleSaveAttachment(final MediaMmsMessageRecord message) { private void handleSaveAttachment(final MediaMmsMessageRecord message) {
@ -875,7 +868,7 @@ public class ConversationFragment extends LoggingFragment {
if (getListAdapter() != null) { if (getListAdapter() != null) {
clearHeaderIfNotTyping(getListAdapter()); clearHeaderIfNotTyping(getListAdapter());
setLastSeen(0); setLastSeen(0);
getListAdapter().addFastRecord(new ConversationMessage(messageRecord)); getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord, messageRecord.getDisplayBody(requireContext()), message.getMentions()));
list.post(() -> list.scrollToPosition(0)); list.post(() -> list.scrollToPosition(0));
} }
@ -888,7 +881,7 @@ public class ConversationFragment extends LoggingFragment {
if (getListAdapter() != null) { if (getListAdapter() != null) {
clearHeaderIfNotTyping(getListAdapter()); clearHeaderIfNotTyping(getListAdapter());
setLastSeen(0); setLastSeen(0);
getListAdapter().addFastRecord(new ConversationMessage(messageRecord)); getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord));
list.post(() -> list.scrollToPosition(0)); list.post(() -> list.scrollToPosition(0));
} }
@ -1017,7 +1010,7 @@ public class ConversationFragment extends LoggingFragment {
public interface ConversationFragmentListener { public interface ConversationFragmentListener {
void setThreadId(long threadId); void setThreadId(long threadId);
void handleReplyMessage(MessageRecord messageRecord); void handleReplyMessage(ConversationMessage conversationMessage);
void onMessageActionToolbarOpened(); void onMessageActionToolbarOpened();
void onForwardClicked(); void onForwardClicked();
void onMessageRequest(@NonNull MessageRequestViewModel viewModel); void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
@ -1306,7 +1299,7 @@ public class ConversationFragment extends LoggingFragment {
} }
@Override @Override
public void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) { public void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) {
if (getContext() == null) return; if (getContext() == null) return;
RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM"); RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM");

View File

@ -26,6 +26,7 @@ import android.graphics.PorterDuff;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.net.Uri; import android.net.Uri;
import android.text.Annotation;
import android.text.Spannable; import android.text.Spannable;
import android.text.SpannableString; import android.text.SpannableString;
import android.text.SpannableStringBuilder; 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.SharedContactView;
import org.thoughtcrime.securesms.components.BorderlessImageView; import org.thoughtcrime.securesms.components.BorderlessImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView; import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.AttachmentDatabase; import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
@ -552,7 +554,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
} else if (isCaptionlessMms(messageRecord)) { } else if (isCaptionlessMms(messageRecord)) {
bodyText.setVisibility(View.GONE); bodyText.setVisibility(View.GONE);
} else { } 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 BackgroundColorSpan(Color.YELLOW), styledText, searchQuery);
styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), 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 -> { contactPhoto.setOnClickListener(v -> {
if (eventListener != null) { 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); messageBody.setSpan(new LongClickCopySpan(urlSpan.getURL()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
} }
} }
List<Annotation> 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; return messageBody;
} }
@ -901,7 +909,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
} }
Quote quote = ((MediaMmsMessageRecord)current).getQuote(); Quote quote = ((MediaMmsMessageRecord)current).getQuote();
//noinspection ConstantConditions //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.setVisibility(View.VISIBLE);
quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; 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() { private void handleMessageApproval() {
final int title; final int title;
final int message; final int message;

View File

@ -1,27 +1,58 @@
package org.thoughtcrime.securesms.conversation; 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.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.Conversions; import org.thoughtcrime.securesms.util.Conversions;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.Collections;
import java.util.List;
/** /**
* A view level model used to pass arbitrary message related information needed * A view level model used to pass arbitrary message related information needed
* for various presentations. * for various presentations.
*/ */
public class ConversationMessage { public class ConversationMessage {
private final MessageRecord messageRecord; @NonNull private final MessageRecord messageRecord;
@NonNull private final List<Mention> 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<Mention> mentions)
{
this.messageRecord = messageRecord; 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() { public @NonNull MessageRecord getMessageRecord() {
return messageRecord; return messageRecord;
} }
public @NonNull List<Mention> getMentions() {
return mentions;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;
@ -41,4 +72,74 @@ public class ConversationMessage {
return Conversions.byteArrayToLong(bytes); 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<Mention> 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<Mention> 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<Mention> 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);
}
}
} }

View File

@ -16,20 +16,24 @@ public class MentionViewHolder extends MappingViewHolder<MentionViewState> {
private final AvatarImageView avatar; private final AvatarImageView avatar;
private final TextView name; private final TextView name;
private final TextView username;
@Nullable private final MentionEventsListener mentionEventsListener; @Nullable private final MentionEventsListener mentionEventsListener;
public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) { public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) {
super(itemView); super(itemView);
this.mentionEventsListener = mentionEventsListener; this.mentionEventsListener = mentionEventsListener;
avatar = findViewById(R.id.mention_recipient_avatar); avatar = findViewById(R.id.mention_recipient_avatar);
name = findViewById(R.id.mention_recipient_name); name = findViewById(R.id.mention_recipient_name);
username = findViewById(R.id.mention_recipient_username);
} }
@Override @Override
public void bind(@NonNull MentionViewState model) { public void bind(@NonNull MentionViewState model) {
avatar.setRecipient(model.getRecipient()); avatar.setRecipient(model.getRecipient());
name.setText(model.getName(context)); name.setText(model.getName(context));
username.setText(model.getUsername());
itemView.setOnClickListener(v -> { itemView.setOnClickListener(v -> {
if (mentionEventsListener != null) { if (mentionEventsListener != null) {
mentionEventsListener.onMentionClicked(model.getRecipient()); mentionEventsListener.onMentionClicked(model.getRecipient());

View File

@ -7,6 +7,7 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingModel; import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.Util;
import java.util.Objects; import java.util.Objects;
@ -26,6 +27,10 @@ public final class MentionViewState implements MappingModel<MentionViewState> {
return recipient; return recipient;
} }
@NonNull String getUsername() {
return Util.emptyIfNull(recipient.getDisplayUsername());
}
@Override @Override
public boolean areItemsTheSame(@NonNull MentionViewState newItem) { public boolean areItemsTheSame(@NonNull MentionViewState newItem) {
return recipient.getId().equals(newItem.recipient.getId()); return recipient.getId().equals(newItem.recipient.getId());

View File

@ -27,13 +27,15 @@ public class MentionsPickerFragment extends LoggingFragment {
private RecyclerView list; private RecyclerView list;
private BottomSheetBehavior<View> behavior; private BottomSheetBehavior<View> behavior;
private MentionsPickerViewModel viewModel; private MentionsPickerViewModel viewModel;
private int defaultPeekHeight;
@Override @Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false); View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false);
list = view.findViewById(R.id.mentions_picker_list); list = view.findViewById(R.id.mentions_picker_list);
behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet)); behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet));
defaultPeekHeight = view.getContext().getResources().getDimensionPixelSize(R.dimen.mentions_picker_peek_height);
return view; return view;
} }
@ -72,13 +74,16 @@ public class MentionsPickerFragment extends LoggingFragment {
if (mappingModels.isEmpty()) { if (mappingModels.isEmpty()) {
updateBottomSheetBehavior(0); updateBottomSheetBehavior(0);
} }
list.scrollToPosition(0);
} }
private void updateBottomSheetBehavior(int count) { private void updateBottomSheetBehavior(int count) {
if (count > 0) { if (count > 0) {
if (behavior.getPeekHeight() == 0) { if (behavior.getPeekHeight() == 0) {
behavior.setPeekHeight(ViewUtil.dpToPx(240), true); behavior.setPeekHeight(defaultPeekHeight, true);
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED); behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
} else {
list.scrollToPosition(0);
} }
} else { } else {
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED); behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);

View File

@ -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<Recipient> search(MentionQuery mentionQuery) {
if (TextUtils.isEmpty(mentionQuery.query)) {
return Collections.emptyList();
}
List<RecipientId> 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<GroupMemberEntry.FullMember> members;
MentionQuery(@NonNull String query, @NonNull List<GroupMemberEntry.FullMember> members) {
this.query = query;
this.members = members;
}
}
}

View File

@ -1,7 +1,5 @@
package org.thoughtcrime.securesms.conversation.ui.mentions; package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.text.TextUtils;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
@ -11,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream; import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup; 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.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Collections;
import java.util.List; import java.util.List;
public class MentionsPickerViewModel extends ViewModel { public class MentionsPickerViewModel extends ViewModel {
@ -28,17 +26,18 @@ public class MentionsPickerViewModel extends ViewModel {
private final SingleLiveEvent<Recipient> selectedRecipient; private final SingleLiveEvent<Recipient> selectedRecipient;
private final LiveData<List<MappingModel<?>>> mentionList; private final LiveData<List<MappingModel<?>>> mentionList;
private final MutableLiveData<LiveGroup> group; private final MutableLiveData<LiveGroup> group;
private final MutableLiveData<CharSequence> liveQuery; private final MutableLiveData<String> liveQuery;
MentionsPickerViewModel() { MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository) {
group = new MutableLiveData<>(); group = new MutableLiveData<>();
liveQuery = new MutableLiveData<>(); liveQuery = new MutableLiveData<>();
selectedRecipient = new SingleLiveEvent<>(); selectedRecipient = new SingleLiveEvent<>();
// TODO [cody] [mentions] simple query support implement for building UI/UX, to be replaced with better search before launch LiveData<List<FullMember>> fullMembers = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers));
LiveData<List<FullMember>> members = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers)); LiveData<String> query = Transformations.distinctUntilChanged(liveQuery);
LiveData<MentionQuery> 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)).<MappingModel<?>>map(MentionViewState::new).toList());
} }
@NonNull LiveData<List<MappingModel<?>>> getMentionList() { @NonNull LiveData<List<MappingModel<?>>> getMentionList() {
@ -54,7 +53,7 @@ public class MentionsPickerViewModel extends ViewModel {
} }
public void onQueryChange(@NonNull CharSequence query) { public void onQueryChange(@NonNull CharSequence query) {
liveQuery.setValue(query); liveQuery.setValue(query.toString());
} }
public void onRecipientChange(@NonNull Recipient recipient) { public void onRecipientChange(@NonNull Recipient recipient) {
@ -65,22 +64,11 @@ public class MentionsPickerViewModel extends ViewModel {
} }
} }
private @NonNull List<MappingModel<?>> filterMembers(@NonNull CharSequence query, @NonNull List<FullMember> 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()))
.<MappingModel<?>>map(m -> new MentionViewState(m.getMember()))
.toList();
}
public static final class Factory implements ViewModelProvider.Factory { public static final class Factory implements ViewModelProvider.Factory {
@Override @Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) { public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions //noinspection ConstantConditions
return modelClass.cast(new MentionsPickerViewModel()); return modelClass.cast(new MentionsPickerViewModel(new MentionsPickerRepository(ApplicationDependencies.getApplication())));
} }
} }
} }

View File

@ -63,6 +63,7 @@ public class DatabaseFactory {
private final KeyValueDatabase keyValueDatabase; private final KeyValueDatabase keyValueDatabase;
private final MegaphoneDatabase megaphoneDatabase; private final MegaphoneDatabase megaphoneDatabase;
private final RemappedRecordsDatabase remappedRecordsDatabase; private final RemappedRecordsDatabase remappedRecordsDatabase;
private final MentionDatabase mentionDatabase;
public static DatabaseFactory getInstance(Context context) { public static DatabaseFactory getInstance(Context context) {
synchronized (lock) { synchronized (lock) {
@ -165,6 +166,10 @@ public class DatabaseFactory {
return getInstance(context).remappedRecordsDatabase; return getInstance(context).remappedRecordsDatabase;
} }
public static MentionDatabase getMentionDatabase(Context context) {
return getInstance(context).mentionDatabase;
}
public static SQLiteDatabase getBackupDatabase(Context context) { public static SQLiteDatabase getBackupDatabase(Context context) {
return getInstance(context).databaseHelper.getReadableDatabase(); return getInstance(context).databaseHelper.getReadableDatabase();
} }
@ -214,6 +219,7 @@ public class DatabaseFactory {
this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper); this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper);
this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper); this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper);
this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper); this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper);
this.mentionDatabase = new MentionDatabase(context, databaseHelper);
} }
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret, public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,

View File

@ -4,6 +4,8 @@ import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import net.sqlcipher.database.SQLiteDatabase; import net.sqlcipher.database.SQLiteDatabase;
@ -73,14 +75,11 @@ public class DraftDatabase extends Database {
db.delete(TABLE_NAME, null, null); db.delete(TABLE_NAME, null, null);
} }
public List<Draft> getDrafts(long threadId) { public Drafts getDrafts(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase(); SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<Draft> results = new LinkedList<>(); Drafts results = new Drafts();
Cursor cursor = null;
try {
cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null);
try (Cursor cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null)) {
while (cursor != null && cursor.moveToNext()) { while (cursor != null && cursor.moveToNext()) {
String type = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_TYPE)); String type = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_TYPE));
String value = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_VALUE)); String value = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_VALUE));
@ -89,9 +88,6 @@ public class DraftDatabase extends Database {
} }
return results; 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 AUDIO = "audio";
public static final String LOCATION = "location"; public static final String LOCATION = "location";
public static final String QUOTE = "quote"; public static final String QUOTE = "quote";
public static final String MENTION = "mention";
private final String type; private final String type;
private final String value; private final String value;
@ -133,7 +130,7 @@ public class DraftDatabase extends Database {
} }
public static class Drafts extends LinkedList<Draft> { public static class Drafts extends LinkedList<Draft> {
private Draft getDraftOfType(String type) { public @Nullable Draft getDraftOfType(String type) {
for (Draft draft : this) { for (Draft draft : this) {
if (type.equals(draft.getType())) { if (type.equals(draft.getType())) {
return draft; return draft;
@ -142,7 +139,7 @@ public class DraftDatabase extends Database {
return null; return null;
} }
public String getSnippet(Context context) { public @NonNull String getSnippet(Context context) {
Draft textDraft = getDraftOfType(Draft.TEXT); Draft textDraft = getDraftOfType(Draft.TEXT);
if (textDraft != null) { if (textDraft != null) {
return textDraft.getSnippet(context); return textDraft.getSnippet(context);

View File

@ -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<Mention> 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<Mention> getMentionsForMessage(long messageId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
List<Mention> 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<Long, List<Mention>> getMentionsForMessages(@NonNull Collection<Long> messageIds) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Map<Long, List<Mention>> 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<Mention> 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;
}
}

View File

@ -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<Mention> 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<Mention> mentions) {
return updateBodyAndMentionsWithDisplayNames(context, messageRecord.getDisplayBody(context), mentions);
}
@WorkerThread
public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithDisplayNames(@NonNull Context context, @NonNull CharSequence body, @NonNull List<Mention> mentions) {
return update(body, mentions, m -> MENTION_STARTER + Recipient.resolved(m.getRecipientId()).getDisplayName(context));
}
public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithPlaceholders(@Nullable CharSequence body, @NonNull List<Mention> mentions) {
return update(body, mentions, m -> MENTION_PLACEHOLDER);
}
private static @NonNull UpdatedBodyAndMentions update(@Nullable CharSequence body, @NonNull List<Mention> mentions, @NonNull Function<Mention, CharSequence> replacementTextGenerator) {
if (body == null || mentions.isEmpty()) {
return new UpdatedBodyAndMentions(body, mentions);
}
SpannableStringBuilder updatedBody = new SpannableStringBuilder();
List<Mention> 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<Mention> 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<Mention> 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<Mention> mentions;
public UpdatedBodyAndMentions(@Nullable CharSequence body, @NonNull List<Mention> mentions) {
this.body = body;
this.mentions = mentions;
}
public @Nullable CharSequence getBody() {
return body;
}
public @NonNull List<Mention> getMentions() {
return mentions;
}
@Nullable String getBodyAsString() {
return body != null ? body.toString() : null;
}
}
}

View File

@ -47,10 +47,12 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.documents.NetworkFailureList; import org.thoughtcrime.securesms.database.documents.NetworkFailureList;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper; import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; 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.MessageRecord;
import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord; import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.Quote; import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.database.model.ReactionRecord; 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.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.TrimThreadJob; import org.thoughtcrime.securesms.jobs.TrimThreadJob;
import org.thoughtcrime.securesms.linkpreview.LinkPreview; 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.recipients.RecipientId;
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo; import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo;
import org.thoughtcrime.securesms.revealable.ViewOnceUtil; import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
import org.thoughtcrime.securesms.util.CursorUtil;
import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util; 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_BODY = "quote_body";
static final String QUOTE_ATTACHMENT = "quote_attachment"; static final String QUOTE_ATTACHMENT = "quote_attachment";
static final String QUOTE_MISSING = "quote_missing"; static final String QUOTE_MISSING = "quote_missing";
static final String QUOTE_MENTIONS = "quote_mentions";
static final String SHARED_CONTACTS = "shared_contacts"; static final String SHARED_CONTACTS = "shared_contacts";
static final String LINK_PREVIEWS = "previews"; static final String LINK_PREVIEWS = "previews";
static final String MENTIONS_SELF = "mentions_self";
public static final String VIEW_ONCE = "reveal_duration"; public static final String VIEW_ONCE = "reveal_duration";
@ -163,6 +168,7 @@ public class MmsDatabase extends MessagingDatabase {
QUOTE_BODY + " TEXT, " + QUOTE_BODY + " TEXT, " +
QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " + QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " +
QUOTE_MISSING + " INTEGER DEFAULT 0, " + QUOTE_MISSING + " INTEGER DEFAULT 0, " +
QUOTE_MENTIONS + " BLOB DEFAULT NULL," +
SHARED_CONTACTS + " TEXT, " + SHARED_CONTACTS + " TEXT, " +
UNIDENTIFIED + " INTEGER DEFAULT 0, " + UNIDENTIFIED + " INTEGER DEFAULT 0, " +
LINK_PREVIEWS + " TEXT, " + LINK_PREVIEWS + " TEXT, " +
@ -170,7 +176,8 @@ public class MmsDatabase extends MessagingDatabase {
REACTIONS + " BLOB DEFAULT NULL, " + REACTIONS + " BLOB DEFAULT NULL, " +
REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + REACTIONS_UNREAD + " INTEGER DEFAULT 0, " +
REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " + 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 = { public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");", "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, MESSAGE_SIZE, STATUS, TRANSACTION_ID,
BODY, PART_COUNT, RECIPIENT_ID, ADDRESS_DEVICE_ID, BODY, PART_COUNT, RECIPIENT_ID, ADDRESS_DEVICE_ID,
DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_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, SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN,
REMOTE_DELETED, REMOTE_DELETED, MENTIONS_SELF,
"json_group_array(json_object(" + "json_group_array(json_object(" +
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " + "'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " + "'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " +
@ -805,6 +812,7 @@ public class MmsDatabase extends MessagingDatabase {
throws MmsException, NoSuchMessageException throws MmsException, NoSuchMessageException
{ {
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context); AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context);
Cursor cursor = null; Cursor cursor = null;
try { try {
@ -812,6 +820,7 @@ public class MmsDatabase extends MessagingDatabase {
if (cursor != null && cursor.moveToNext()) { if (cursor != null && cursor.moveToNext()) {
List<DatabaseAttachment> associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId); List<DatabaseAttachment> associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId);
List<Mention> mentions = mentionDatabase.getMentionsForMessage(messageId);
long outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX)); long outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX));
String body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)); String body = cursor.getString(cursor.getColumnIndexOrThrow(BODY));
@ -830,6 +839,7 @@ public class MmsDatabase extends MessagingDatabase {
String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)); String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY));
boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1; boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1;
List<Attachment> quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList(); List<Attachment> quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList();
List<Mention> quoteMentions = parseQuoteMentions(cursor);
List<Contact> contacts = getSharedContacts(cursor, associatedAttachments); List<Contact> contacts = getSharedContacts(cursor, associatedAttachments);
Set<Attachment> contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList()); Set<Attachment> contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList());
List<LinkPreview> previews = getLinkPreviews(cursor, associatedAttachments); List<LinkPreview> previews = getLinkPreviews(cursor, associatedAttachments);
@ -846,7 +856,7 @@ public class MmsDatabase extends MessagingDatabase {
QuoteModel quote = null; QuoteModel quote = null;
if (quoteId > 0 && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || !quoteAttachments.isEmpty())) { 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)) { if (!TextUtils.isEmpty(mismatchDocument)) {
@ -866,12 +876,12 @@ public class MmsDatabase extends MessagingDatabase {
} }
if (body != null && (Types.isGroupQuit(outboxType) || Types.isGroupUpdate(outboxType))) { 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)) { } else if (Types.isExpirationTimerUpdate(outboxType)) {
return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn); 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)) { if (Types.isSecureType(outboxType)) {
return new OutgoingSecureMediaMessage(message); return new OutgoingSecureMediaMessage(message);
@ -1000,10 +1010,15 @@ public class MmsDatabase extends MessagingDatabase {
if (retrieved.getQuote() != null) { if (retrieved.getQuote() != null) {
contentValues.put(QUOTE_ID, retrieved.getQuote().getId()); 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_AUTHOR, retrieved.getQuote().getAuthor().serialize());
contentValues.put(QUOTE_MISSING, retrieved.getQuote().isOriginalMissing() ? 1 : 0); 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(); quoteAttachments = retrieved.getQuote().getAttachments();
} }
@ -1012,7 +1027,7 @@ public class MmsDatabase extends MessagingDatabase {
return Optional.absent(); 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)) { if (!Types.isExpirationTimerUpdate(mailbox)) {
DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1); DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1);
@ -1158,15 +1173,23 @@ public class MmsDatabase extends MessagingDatabase {
List<Attachment> quoteAttachments = new LinkedList<>(); List<Attachment> quoteAttachments = new LinkedList<>();
if (message.getOutgoingQuote() != null) { 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_ID, message.getOutgoingQuote().getId());
contentValues.put(QUOTE_AUTHOR, message.getOutgoingQuote().getAuthor().serialize()); 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); 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()); 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()) { if (message.getRecipient().isGroup()) {
OutgoingGroupUpdateMessage outgoingGroupUpdateMessage = (message instanceof OutgoingGroupUpdateMessage) ? (OutgoingGroupUpdateMessage) message : null; OutgoingGroupUpdateMessage outgoingGroupUpdateMessage = (message instanceof OutgoingGroupUpdateMessage) ? (OutgoingGroupUpdateMessage) message : null;
@ -1197,17 +1220,22 @@ public class MmsDatabase extends MessagingDatabase {
return messageId; return messageId;
} }
private long insertMediaMessage(@Nullable String body, private long insertMediaMessage(long threadId,
@Nullable String body,
@NonNull List<Attachment> attachments, @NonNull List<Attachment> attachments,
@NonNull List<Attachment> quoteAttachments, @NonNull List<Attachment> quoteAttachments,
@NonNull List<Contact> sharedContacts, @NonNull List<Contact> sharedContacts,
@NonNull List<LinkPreview> linkPreviews, @NonNull List<LinkPreview> linkPreviews,
@NonNull List<Mention> mentions,
@NonNull ContentValues contentValues, @NonNull ContentValues contentValues,
@Nullable SmsDatabase.InsertListener insertListener) @Nullable SmsDatabase.InsertListener insertListener)
throws MmsException throws MmsException
{ {
SQLiteDatabase db = databaseHelper.getWritableDatabase(); SQLiteDatabase db = databaseHelper.getWritableDatabase();
AttachmentDatabase partsDatabase = DatabaseFactory.getAttachmentDatabase(context); 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<Attachment> allAttachments = new LinkedList<>(); List<Attachment> allAttachments = new LinkedList<>();
List<Attachment> contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList(); List<Attachment> 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(BODY, body);
contentValues.put(PART_COUNT, allAttachments.size()); contentValues.put(PART_COUNT, allAttachments.size());
contentValues.put(MENTIONS_SELF, mentionsSelf ? 1 : 0);
db.beginTransaction(); db.beginTransaction();
try { try {
long messageId = db.insert(TABLE_NAME, null, contentValues); long messageId = db.insert(TABLE_NAME, null, contentValues);
mentionDatabase.insert(threadId, messageId, mentions);
Map<Attachment, AttachmentId> insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments); Map<Attachment, AttachmentId> insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments);
String serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts); String serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts);
String serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews); String serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews);
@ -1468,6 +1499,12 @@ public class MmsDatabase extends MessagingDatabase {
} }
} }
private @NonNull List<Mention> parseQuoteMentions(Cursor cursor) {
byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_MENTIONS));
return MentionUtil.bodyRangeListToMentions(context, raw);
}
public void beginTransaction() { public void beginTransaction() {
databaseHelper.getWritableDatabase().beginTransaction(); databaseHelper.getWritableDatabase().beginTransaction();
} }
@ -1542,6 +1579,16 @@ public class MmsDatabase extends MessagingDatabase {
public MessageRecord getCurrent() { public MessageRecord getCurrent() {
SlideDeck slideDeck = new SlideDeck(context, message.getAttachments()); SlideDeck slideDeck = new SlideDeck(context, message.getAttachments());
CharSequence quoteText = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getText() : null;
List<Mention> 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, return new MediaMmsMessageRecord(id,
message.getRecipient(), message.getRecipient(),
message.getRecipient(), message.getRecipient(),
@ -1564,14 +1611,16 @@ public class MmsDatabase extends MessagingDatabase {
message.getOutgoingQuote() != null ? message.getOutgoingQuote() != null ?
new Quote(message.getOutgoingQuote().getId(), new Quote(message.getOutgoingQuote().getId(),
message.getOutgoingQuote().getAuthor(), message.getOutgoingQuote().getAuthor(),
message.getOutgoingQuote().getText(), quoteText,
message.getOutgoingQuote().isOriginalMissing(), message.getOutgoingQuote().isOriginalMissing(),
new SlideDeck(context, message.getOutgoingQuote().getAttachments())) : new SlideDeck(context, message.getOutgoingQuote().getAttachments()),
quoteMentions) :
null, null,
message.getSharedContacts(), message.getSharedContacts(),
message.getLinkPreviews(), message.getLinkPreviews(),
false, false,
Collections.emptyList(), Collections.emptyList(),
false,
false); false);
} }
} }
@ -1665,6 +1714,7 @@ public class MmsDatabase extends MessagingDatabase {
boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1; boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1;
boolean remoteDelete = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REMOTE_DELETED)) == 1; boolean remoteDelete = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REMOTE_DELETED)) == 1;
List<ReactionRecord> reactions = parseReactions(cursor); List<ReactionRecord> reactions = parseReactions(cursor);
boolean mentionsSelf = CursorUtil.requireBoolean(cursor, MENTIONS_SELF);
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0; readReceiptCount = 0;
@ -1686,7 +1736,7 @@ public class MmsDatabase extends MessagingDatabase {
threadId, body, slideDeck, partCount, box, mismatches, threadId, body, slideDeck, partCount, box, mismatches,
networkFailures, subscriptionId, expiresIn, expireStarted, networkFailures, subscriptionId, expiresIn, expireStarted,
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions, isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions,
remoteDelete); remoteDelete, mentionsSelf);
} }
private List<IdentityKeyMismatch> getMismatchedIdentities(String document) { private List<IdentityKeyMismatch> getMismatchedIdentities(String document) {
@ -1724,14 +1774,22 @@ public class MmsDatabase extends MessagingDatabase {
private @Nullable Quote getQuote(@NonNull Cursor cursor) { private @Nullable Quote getQuote(@NonNull Cursor cursor) {
long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_ID)); long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_ID));
long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_AUTHOR)); 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; boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_MISSING)) == 1;
List<Mention> quoteMentions = parseQuoteMentions(cursor);
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor); List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor);
List<? extends Attachment> quoteAttachments = Stream.of(attachments).filter(Attachment::isQuote).toList(); List<? extends Attachment> quoteAttachments = Stream.of(attachments).filter(Attachment::isQuote).toList();
SlideDeck quoteDeck = new SlideDeck(context, quoteAttachments); SlideDeck quoteDeck = new SlideDeck(context, quoteAttachments);
if (quoteId > 0 && quoteAuthor > 0) { 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 { } else {
return null; return null;
} }

View File

@ -82,6 +82,7 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.QUOTE_MENTIONS,
MmsDatabase.SHARED_CONTACTS, MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS, MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE, MmsDatabase.VIEW_ONCE,
@ -89,7 +90,8 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.REACTIONS, MmsSmsColumns.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD, MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN, MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.REMOTE_DELETED}; MmsSmsColumns.REMOTE_DELETED,
MmsDatabase.MENTIONS_SELF};
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) { public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper); super(context, databaseHelper);
@ -408,6 +410,7 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.QUOTE_MENTIONS,
MmsDatabase.SHARED_CONTACTS, MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS, MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE, MmsDatabase.VIEW_ONCE,
@ -415,7 +418,8 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.REACTIONS_UNREAD, MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN, MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.DATE_SERVER, MmsSmsColumns.DATE_SERVER,
MmsSmsColumns.REMOTE_DELETED }; MmsSmsColumns.REMOTE_DELETED,
MmsDatabase.MENTIONS_SELF };
String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT, String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED, SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
@ -440,6 +444,7 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY, MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING, MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT, MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.QUOTE_MENTIONS,
MmsDatabase.SHARED_CONTACTS, MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS, MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE, MmsDatabase.VIEW_ONCE,
@ -447,7 +452,8 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.REACTIONS_UNREAD, MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN, MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.DATE_SERVER, MmsSmsColumns.DATE_SERVER,
MmsSmsColumns.REMOTE_DELETED }; MmsSmsColumns.REMOTE_DELETED,
MmsDatabase.MENTIONS_SELF };
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder(); SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
@ -493,6 +499,7 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.QUOTE_BODY); mmsColumnsPresent.add(MmsDatabase.QUOTE_BODY);
mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING); mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING);
mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT); mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT);
mmsColumnsPresent.add(MmsDatabase.QUOTE_MENTIONS);
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS); mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS); mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE); mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE);
@ -500,6 +507,7 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD); mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD);
mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN); mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN);
mmsColumnsPresent.add(MmsDatabase.REMOTE_DELETED); mmsColumnsPresent.add(MmsDatabase.REMOTE_DELETED);
mmsColumnsPresent.add(MmsDatabase.MENTIONS_SELF);
Set<String> smsColumnsPresent = new HashSet<>(); Set<String> smsColumnsPresent = new HashSet<>();
smsColumnsPresent.add(MmsSmsColumns.ID); smsColumnsPresent.add(MmsSmsColumns.ID);

View File

@ -119,13 +119,13 @@ public class RecipientDatabase extends Database {
private static final String PROFILE_GIVEN_NAME = "signal_profile_name"; 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_FAMILY_NAME = "profile_family_name";
private static final String PROFILE_JOINED_NAME = "profile_joined_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"; public static final String SEARCH_PROFILE_NAME = "search_signal_profile";
private static final String SORT_NAME = "sort_name"; private static final String SORT_NAME = "sort_name";
private static final String IDENTITY_STATUS = "identity_status"; private static final String IDENTITY_STATUS = "identity_status";
private static final String IDENTITY_KEY = "identity_key"; private static final String IDENTITY_KEY = "identity_key";
private static final String[] RECIPIENT_PROJECTION = new String[] { private static final String[] RECIPIENT_PROJECTION = new String[] {
UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE, 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, 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, UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION, FORCE_SMS_SELECTION,
UUID_CAPABILITY, GROUPS_V2_CAPABILITY, UUID_CAPABILITY, GROUPS_V2_CAPABILITY,
STORAGE_SERVICE_ID, DIRTY STORAGE_SERVICE_ID, DIRTY,
MENTION_SETTING
}; };
private static final String[] ID_PROJECTION = new String[]{ID}; private static final String[] ID_PROJECTION = new String[]{ID};
@ -146,6 +147,8 @@ public class RecipientDatabase extends Database {
.map(columnName -> TABLE_NAME + "." + columnName) .map(columnName -> TABLE_NAME + "." + columnName)
.toList().toArray(new String[0]); .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( private static final String[] RECIPIENT_FULL_PROJECTION = ArrayUtils.concat(
new String[] { TABLE_NAME + "." + ID }, new String[] { TABLE_NAME + "." + ID },
TYPED_RECIPIENT_PROJECTION, 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 = public static final String CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
UUID + " TEXT UNIQUE DEFAULT NULL, " + UUID + " TEXT UNIQUE DEFAULT NULL, " +
@ -314,7 +335,8 @@ public class RecipientDatabase extends Database {
UUID_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " + UUID_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " +
GROUPS_V2_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " + GROUPS_V2_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " +
STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " + 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 + private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
" FROM " + TABLE_NAME + " FROM " + TABLE_NAME +
@ -1078,6 +1100,7 @@ public class RecipientDatabase extends Database {
int uuidCapabilityValue = CursorUtil.requireInt(cursor, UUID_CAPABILITY); int uuidCapabilityValue = CursorUtil.requireInt(cursor, UUID_CAPABILITY);
int groupsV2CapabilityValue = CursorUtil.requireInt(cursor, GROUPS_V2_CAPABILITY); int groupsV2CapabilityValue = CursorUtil.requireInt(cursor, GROUPS_V2_CAPABILITY);
String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID); String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID);
int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING);
Optional<String> identityKeyRaw = CursorUtil.getString(cursor, IDENTITY_KEY); Optional<String> identityKeyRaw = CursorUtil.getString(cursor, IDENTITY_KEY);
Optional<Integer> identityStatusRaw = CursorUtil.getInt(cursor, IDENTITY_STATUS); Optional<Integer> identityStatusRaw = CursorUtil.getInt(cursor, IDENTITY_STATUS);
@ -1145,7 +1168,7 @@ public class RecipientDatabase extends Database {
Recipient.Capability.deserialize(uuidCapabilityValue), Recipient.Capability.deserialize(uuidCapabilityValue),
Recipient.Capability.deserialize(groupsV2CapabilityValue), Recipient.Capability.deserialize(groupsV2CapabilityValue),
InsightsBannerTier.fromId(insightsBannerTier), InsightsBannerTier.fromId(insightsBannerTier),
storageKey, identityKey, identityStatus); storageKey, identityKey, identityStatus, MentionSetting.fromId(mentionSettingId));
} }
public BulkOperationsHandle beginBulkSystemContactUpdate() { 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. * Updates the profile key.
* <p> * <p>
@ -1902,6 +1933,29 @@ public class RecipientDatabase extends Database {
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null); return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null);
} }
public @NonNull List<Recipient> queryRecipientsForMentions(@NonNull String query, @NonNull List<RecipientId> 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<Recipient> 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 * Builds a case-insensitive GLOB pattern for fuzzy text queries. Works with all unicode
* characters. * characters.
@ -2384,6 +2438,10 @@ public class RecipientDatabase extends Database {
return "NULLIF(" + column + ", '')"; return "NULLIF(" + column + ", '')";
} }
private static @NonNull String removeWhitespace(@NonNull String column) {
return "REPLACE(" + column + ", ' ', '')";
}
public interface ColorUpdater { public interface ColorUpdater {
MaterialColor update(@NonNull String name, @Nullable String color); MaterialColor update(@NonNull String name, @Nullable String color);
} }
@ -2427,6 +2485,7 @@ public class RecipientDatabase extends Database {
private final byte[] storageId; private final byte[] storageId;
private final byte[] identityKey; private final byte[] identityKey;
private final IdentityDatabase.VerifiedStatus identityStatus; private final IdentityDatabase.VerifiedStatus identityStatus;
private final MentionSetting mentionSetting;
RecipientSettings(@NonNull RecipientId id, RecipientSettings(@NonNull RecipientId id,
@Nullable UUID uuid, @Nullable UUID uuid,
@ -2465,7 +2524,8 @@ public class RecipientDatabase extends Database {
@NonNull InsightsBannerTier insightsBannerTier, @NonNull InsightsBannerTier insightsBannerTier,
@Nullable byte[] storageId, @Nullable byte[] storageId,
@Nullable byte[] identityKey, @Nullable byte[] identityKey,
@NonNull IdentityDatabase.VerifiedStatus identityStatus) @NonNull IdentityDatabase.VerifiedStatus identityStatus,
@NonNull MentionSetting mentionSetting)
{ {
this.id = id; this.id = id;
this.uuid = uuid; this.uuid = uuid;
@ -2505,6 +2565,7 @@ public class RecipientDatabase extends Database {
this.storageId = storageId; this.storageId = storageId;
this.identityKey = identityKey; this.identityKey = identityKey;
this.identityStatus = identityStatus; this.identityStatus = identityStatus;
this.mentionSetting = mentionSetting;
} }
public RecipientId getId() { public RecipientId getId() {
@ -2661,6 +2722,10 @@ public class RecipientDatabase extends Database {
public @NonNull IdentityDatabase.VerifiedStatus getIdentityStatus() { public @NonNull IdentityDatabase.VerifiedStatus getIdentityStatus() {
return identityStatus; return identityStatus;
} }
public @NonNull MentionSetting getMentionSetting() {
return mentionSetting;
}
} }
public static class RecipientReader implements Closeable { public static class RecipientReader implements Closeable {

View File

@ -66,7 +66,7 @@ public final class ThreadBodyUtil {
return context.getString(R.string.ThreadRecord_media_message); return context.getString(R.string.ThreadRecord_media_message);
} else { } 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()); 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) { private static @NonNull String getBodyOrDefault(@NonNull Context context, @NonNull MessageRecord record, @StringRes int defaultStringRes) {
if (TextUtils.isEmpty(record.getBody())) { return TextUtils.isEmpty(record.getBody()) ? context.getString(defaultStringRes) : getBody(context, record);
return context.getString(defaultStringRes); }
} else {
return record.getBody(); private static @NonNull String getBody(@NonNull Context context, @NonNull MessageRecord record) {
} return MentionUtil.updateBodyWithDisplayNames(context, record, record.getBody()).toString();
} }
} }

View File

@ -22,6 +22,7 @@ import net.sqlcipher.database.SQLiteDatabaseHook;
import net.sqlcipher.database.SQLiteOpenHelper; import net.sqlcipher.database.SQLiteOpenHelper;
import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy; import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy;
import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.RemappedRecordsDatabase; import org.thoughtcrime.securesms.database.RemappedRecordsDatabase;
import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.ProfileName; 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 QUOTE_CLEANUP = 65;
private static final int BORDERLESS = 66; private static final int BORDERLESS = 66;
private static final int REMAPPED_RECORDS = 67; 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 static final String DATABASE_NAME = "signal.db";
private final Context context; private final Context context;
@ -184,6 +186,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(StorageKeyDatabase.CREATE_TABLE); db.execSQL(StorageKeyDatabase.CREATE_TABLE);
db.execSQL(KeyValueDatabase.CREATE_TABLE); db.execSQL(KeyValueDatabase.CREATE_TABLE);
db.execSQL(MegaphoneDatabase.CREATE_TABLE); db.execSQL(MegaphoneDatabase.CREATE_TABLE);
db.execSQL(MentionDatabase.CREATE_TABLE);
executeStatements(db, SearchDatabase.CREATE_TABLE); executeStatements(db, SearchDatabase.CREATE_TABLE);
executeStatements(db, JobDatabase.CREATE_TABLE); executeStatements(db, JobDatabase.CREATE_TABLE);
executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE); executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE);
@ -198,6 +201,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES); executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
executeStatements(db, StickerDatabase.CREATE_INDEXES); executeStatements(db, StickerDatabase.CREATE_INDEXES);
executeStatements(db, StorageKeyDatabase.CREATE_INDEXES); executeStatements(db, StorageKeyDatabase.CREATE_INDEXES);
executeStatements(db, MentionDatabase.CREATE_INDEXES);
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) { if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context); ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context);
@ -970,6 +974,23 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
"new_id INTEGER)"); "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(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -45,6 +45,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
private final static String TAG = MediaMmsMessageRecord.class.getSimpleName(); private final static String TAG = MediaMmsMessageRecord.class.getSimpleName();
private final int partCount; private final int partCount;
private final boolean mentionsSelf;
public MediaMmsMessageRecord(long id, public MediaMmsMessageRecord(long id,
Recipient conversationRecipient, Recipient conversationRecipient,
@ -71,19 +72,26 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
@NonNull List<LinkPreview> linkPreviews, @NonNull List<LinkPreview> linkPreviews,
boolean unidentified, boolean unidentified,
@NonNull List<ReactionRecord> reactions, @NonNull List<ReactionRecord> reactions,
boolean remoteDelete) boolean remoteDelete,
boolean mentionsSelf)
{ {
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck, subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete); readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete);
this.partCount = partCount; this.partCount = partCount;
this.mentionsSelf = mentionsSelf;
} }
public int getPartCount() { public int getPartCount() {
return partCount; return partCount;
} }
@Override
public boolean hasSelfMention() {
return mentionsSelf;
}
@Override @Override
public boolean isMmsNotification() { public boolean isMmsNotification() {
return false; return false;

View File

@ -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<Mention>, 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<Mention> CREATOR = new Creator<Mention>() {
@Override
public Mention createFromParcel(Parcel in) {
return new Mention(in);
}
@Override
public Mention[] newArray(int size) {
return new Mention[size];
}
};
}

View File

@ -374,4 +374,8 @@ public abstract class MessageRecord extends DisplayRecord {
public @NonNull List<ReactionRecord> getReactions() { public @NonNull List<ReactionRecord> getReactions() {
return reactions; return reactions;
} }
public boolean hasSelfMention() {
return false;
}
} }

View File

@ -1,26 +1,42 @@
package org.thoughtcrime.securesms.database.model; package org.thoughtcrime.securesms.database.model;
import android.text.SpannableString;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.List;
public class Quote { public class Quote {
private final long id; private final long id;
private final RecipientId author; private final RecipientId author;
private final String text; private final CharSequence text;
private final boolean missing; private final boolean missing;
private final SlideDeck attachment; private final SlideDeck attachment;
private final List<Mention> mentions;
public Quote(long id, @NonNull RecipientId author, @Nullable String text, boolean missing, @NonNull SlideDeck attachment) { public Quote(long id,
this.id = id; @NonNull RecipientId author,
this.author = author; @Nullable CharSequence text,
this.text = text; boolean missing,
this.missing = missing; @NonNull SlideDeck attachment,
this.attachment = attachment; @NonNull List<Mention> 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() { public long getId() {
@ -31,7 +47,7 @@ public class Quote {
return author; return author;
} }
public @Nullable String getText() { public @Nullable CharSequence getDisplayText() {
return text; return text;
} }

View File

@ -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); 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); long threadId = MessageSender.send(context, outgoingMessage, -1, false, null);
return new GroupActionResult(groupRecipient, threadId, newMemberCount, Collections.emptyList()); return new GroupActionResult(groupRecipient, threadId, newMemberCount, Collections.emptyList());
@ -241,6 +241,7 @@ final class GroupManagerV1 {
false, false,
null, null,
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList())); Collections.emptyList()));
} }
} }

View File

@ -519,6 +519,7 @@ final class GroupManagerV2 {
false, false,
null, null,
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) { if (plainGroupChange != null && DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(plainGroupChange)) {

View File

@ -240,7 +240,7 @@ public final class GroupV1MessageProcessor {
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupId.v1orThrow(group.getGroupId())); RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupId.v1orThrow(group.getGroupId()));
Recipient recipient = Recipient.resolved(recipientId); 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 threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);

View File

@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment; import org.thoughtcrime.securesms.recipients.ui.notifications.CustomNotificationsDialogFragment;
import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleCursorWrapper; import org.thoughtcrime.securesms.util.LifecycleCursorWrapper;
import org.thoughtcrime.securesms.util.views.LearnMoreTextView; import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
@ -99,6 +100,8 @@ public class ManageGroupFragment extends LoggingFragment {
private TextView muteNotificationsUntilLabel; private TextView muteNotificationsUntilLabel;
private TextView customNotificationsButton; private TextView customNotificationsButton;
private View customNotificationsRow; private View customNotificationsRow;
private View mentionsRow;
private TextView mentionsValue;
private View toggleAllMembers; private View toggleAllMembers;
private final Recipient.FallbackPhotoProvider fallbackPhotoProvider = new Recipient.FallbackPhotoProvider() { 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); muteNotificationsRow = view.findViewById(R.id.group_mute_notifications_row);
customNotificationsButton = view.findViewById(R.id.group_custom_notifications_button); customNotificationsButton = view.findViewById(R.id.group_custom_notifications_button);
customNotificationsRow = view.findViewById(R.id.group_custom_notifications_row); 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); toggleAllMembers = view.findViewById(R.id.toggle_all_members);
groupV1Indicator.setOnLinkClickListener(v -> GroupsLearnMoreBottomSheetDialogFragment.show(requireFragmentManager())); groupV1Indicator.setOnLinkClickListener(v -> GroupsLearnMoreBottomSheetDialogFragment.show(requireFragmentManager()));
@ -317,7 +322,6 @@ public class ManageGroupFragment extends LoggingFragment {
customNotificationsRow.setVisibility(View.VISIBLE); customNotificationsRow.setVisibility(View.VISIBLE);
//noinspection CodeBlock2Expr
if (NotificationChannels.supported()) { if (NotificationChannels.supported()) {
viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> { viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> {
customNotificationsButton.setText(hasCustomNotifications ? R.string.ManageGroupActivity_on 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.getSnackbarEvents().observe(getViewLifecycleOwner(), this::handleSnackbarEvent);
viewModel.getInvitedDialogEvents().observe(getViewLifecycleOwner(), this::handleInvitedDialogEvent); viewModel.getInvitedDialogEvents().observe(getViewLifecycleOwner(), this::handleInvitedDialogEvent);

View File

@ -12,6 +12,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.thoughtcrime.securesms.ContactSelectionListFragment; import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.groups.GroupAccessControl; import org.thoughtcrime.securesms.groups.GroupAccessControl;
import org.thoughtcrime.securesms.groups.GroupChangeException; 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 { static final class GroupStateResult {
private final long threadId; private final long threadId;

View File

@ -22,6 +22,8 @@ import org.thoughtcrime.securesms.ExpirationDialog;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader; import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.database.MediaDatabase; 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.MediaLoader;
import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader; import org.thoughtcrime.securesms.database.loaders.ThreadMediaLoader;
import org.thoughtcrime.securesms.groups.GroupAccessControl; 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.GroupErrors;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry; import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity; 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.notifications.NotificationChannels;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
@ -75,6 +78,7 @@ public class ManageGroupViewModel extends ViewModel {
private final LiveData<Boolean> canLeaveGroup; private final LiveData<Boolean> canLeaveGroup;
private final LiveData<Boolean> canBlockGroup; private final LiveData<Boolean> canBlockGroup;
private final LiveData<Boolean> showLegacyIndicator; private final LiveData<Boolean> showLegacyIndicator;
private final LiveData<String> mentionSetting;
private ManageGroupViewModel(@NonNull Context context, @NonNull ManageGroupRepository manageGroupRepository) { private ManageGroupViewModel(@NonNull Context context, @NonNull ManageGroupRepository manageGroupRepository) {
this.context = context; this.context = context;
@ -114,6 +118,8 @@ public class ManageGroupViewModel extends ViewModel {
recipient -> recipient.getNotificationChannel() != null || !NotificationChannels.supported()); recipient -> recipient.getNotificationChannel() != null || !NotificationChannels.supported());
this.canLeaveGroup = liveGroup.isActive(); this.canLeaveGroup = liveGroup.isActive();
this.canBlockGroup = Transformations.map(this.groupRecipient, recipient -> !recipient.isBlocked()); this.canBlockGroup = Transformations.map(this.groupRecipient, recipient -> !recipient.isBlocked());
this.mentionSetting = Transformations.distinctUntilChanged(Transformations.map(this.groupRecipient,
recipient -> MentionUtil.getMentionSettingDisplayValue(context, recipient.getMentionSetting())));
} }
@WorkerThread @WorkerThread
@ -207,6 +213,10 @@ public class ManageGroupViewModel extends ViewModel {
return canLeaveGroup; return canLeaveGroup;
} }
LiveData<String> getMentionSetting() {
return mentionSetting;
}
void handleExpirationSelection() { void handleExpirationSelection() {
manageGroupRepository.getRecipient(groupRecipient -> manageGroupRepository.getRecipient(groupRecipient ->
ExpirationDialog.show(context, ExpirationDialog.show(context,
@ -250,6 +260,10 @@ public class ManageGroupViewModel extends ViewModel {
memberListCollapseState.setValue(CollapseState.OPEN); memberListCollapseState.setValue(CollapseState.OPEN);
} }
void handleMentionNotificationSelection() {
manageGroupRepository.getRecipient(r -> GroupMentionSettingDialog.show(context, r.getMentionSetting(), mentionSetting -> manageGroupRepository.setMentionSetting(mentionSetting)));
}
private void onBlockAndLeaveConfirmed() { private void onBlockAndLeaveConfirmed() {
SimpleProgressDialog.DismissibleDialog dismissibleDialog = SimpleProgressDialog.showDelayed(context); SimpleProgressDialog.DismissibleDialog dismissibleDialog = SimpleProgressDialog.showDelayed(context);

View File

@ -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<MentionSetting> 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<MentionSetting> callback;
public SelectionCallback(@NonNull MentionSetting previousMentionSetting, @Nullable Consumer<MentionSetting> 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);
}
}
}
}

View File

@ -232,6 +232,7 @@ public final class GroupsV2StateProcessor {
false, false,
null, null,
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());
try { try {
@ -397,7 +398,7 @@ public final class GroupsV2StateProcessor {
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupId); RecipientId recipientId = recipientDatabase.getOrInsertFromGroupId(groupId);
Recipient recipient = Recipient.resolved(recipientId); 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 threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);

View File

@ -286,6 +286,7 @@ public final class PushGroupSendJob extends PushSendJob {
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message); Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message); List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<Preview> previews = getPreviewsFor(message); List<Preview> previews = getPreviewsFor(message);
List<SignalServiceDataMessage.Mention> mentions = getMentionsFor(message.getMentions());
List<SignalServiceAddress> addresses = Stream.of(destinations).map(this::getPushAddress).toList(); List<SignalServiceAddress> addresses = Stream.of(destinations).map(this::getPushAddress).toList();
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList(); List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments); List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
@ -352,6 +353,7 @@ public final class PushGroupSendJob extends PushSendJob {
.withSticker(sticker.orNull()) .withSticker(sticker.orNull())
.withSharedContacts(sharedContacts) .withSharedContacts(sharedContacts)
.withPreviews(previews) .withPreviews(previews)
.withMentions(mentions)
.build(); .build();
Log.i(TAG, JobLogger.format(this, "Beginning message send.")); Log.i(TAG, JobLogger.format(this, "Beginning message send."));

View File

@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase; import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase; 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.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord; 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.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress; import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException; import java.io.IOException;
import java.security.SecureRandom; import java.security.SecureRandom;
@ -133,6 +135,7 @@ import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
public final class PushProcessMessageJob extends BaseJob { public final class PushProcessMessageJob extends BaseJob {
@ -341,7 +344,7 @@ public final class PushProcessMessageJob extends BaseJob {
if (content.getDataMessage().isPresent()) { if (content.getDataMessage().isPresent()) {
SignalServiceDataMessage message = content.getDataMessage().get(); 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> groupId = GroupUtil.idFromGroupContext(message.getGroupContext()); Optional<GroupId> groupId = GroupUtil.idFromGroupContext(message.getGroupContext());
boolean isGv2Message = groupId.isPresent() && groupId.get().isV2(); 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(), Optional.absent(),
Optional.absent(),
Optional.absent()); Optional.absent());
database.insertSecureDecryptedMessageInbox(mediaMessage, -1); database.insertSecureDecryptedMessageInbox(mediaMessage, -1);
@ -1029,6 +1033,7 @@ public final class PushProcessMessageJob extends BaseJob {
Optional<QuoteModel> quote = getValidatedQuote(message.getQuote()); Optional<QuoteModel> quote = getValidatedQuote(message.getQuote());
Optional<List<Contact>> sharedContacts = getContacts(message.getSharedContacts()); Optional<List<Contact>> sharedContacts = getContacts(message.getSharedContacts());
Optional<List<LinkPreview>> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or("")); Optional<List<LinkPreview>> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or(""));
Optional<List<Mention>> mentions = getMentions(message.getMentions());
Optional<Attachment> sticker = getStickerAttachment(message.getSticker()); Optional<Attachment> sticker = getStickerAttachment(message.getSticker());
IncomingMediaMessage mediaMessage = new IncomingMediaMessage(RecipientId.fromHighTrust(content.getSender()), IncomingMediaMessage mediaMessage = new IncomingMediaMessage(RecipientId.fromHighTrust(content.getSender()),
message.getTimestamp(), message.getTimestamp(),
@ -1044,6 +1049,7 @@ public final class PushProcessMessageJob extends BaseJob {
quote, quote,
sharedContacts, sharedContacts,
linkPreviews, linkPreviews,
mentions,
sticker); sticker);
insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1); insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1);
@ -1109,6 +1115,7 @@ public final class PushProcessMessageJob extends BaseJob {
Optional<Attachment> sticker = getStickerAttachment(message.getMessage().getSticker()); Optional<Attachment> sticker = getStickerAttachment(message.getMessage().getSticker());
Optional<List<Contact>> sharedContacts = getContacts(message.getMessage().getSharedContacts()); Optional<List<Contact>> sharedContacts = getContacts(message.getMessage().getSharedContacts());
Optional<List<LinkPreview>> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or("")); Optional<List<LinkPreview>> previews = getLinkPreviews(message.getMessage().getPreviews(), message.getMessage().getBody().or(""));
Optional<List<Mention>> mentions = getMentions(message.getMessage().getMentions());
boolean viewOnce = message.getMessage().isViewOnce(); boolean viewOnce = message.getMessage().isViewOnce();
List<Attachment> syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false)) List<Attachment> syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false))
: PointerAttachment.forPointers(message.getMessage().getAttachments()); : PointerAttachment.forPointers(message.getMessage().getAttachments());
@ -1125,6 +1132,7 @@ public final class PushProcessMessageJob extends BaseJob {
ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(), ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(),
sharedContacts.or(Collections.emptyList()), sharedContacts.or(Collections.emptyList()),
previews.or(Collections.emptyList()), previews.or(Collections.emptyList()),
mentions.or(Collections.emptyList()),
Collections.emptyList(), Collections.emptyList()); Collections.emptyList(), Collections.emptyList());
mediaMessage = new OutgoingSecureMediaMessage(mediaMessage); mediaMessage = new OutgoingSecureMediaMessage(mediaMessage);
@ -1290,7 +1298,18 @@ public final class PushProcessMessageJob extends BaseJob {
long messageId; long messageId;
if (isGroup) { 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); outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage);
messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null); 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..."); Log.i(TAG, "Found matching message record...");
List<Attachment> attachments = new LinkedList<>(); List<Attachment> attachments = new LinkedList<>();
List<Mention> mentions = new LinkedList<>();
if (message.isMms()) { if (message.isMms()) {
MmsMessageRecord mmsMessage = (MmsMessageRecord) message; MmsMessageRecord mmsMessage = (MmsMessageRecord) message;
mentions.addAll(DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(mmsMessage.getId()));
if (mmsMessage.isViewOnce()) { if (mmsMessage.isViewOnce()) {
attachments.add(new TombstoneAttachment(MediaUtil.VIEW_ONCE, true)); attachments.add(new TombstoneAttachment(MediaUtil.VIEW_ONCE, true));
} else { } 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) { } else if (message != null) {
Log.w(TAG, "Found the target for the quote, but it's flagged as remotely deleted."); 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, author,
quote.get().getText(), quote.get().getText(),
true, true,
PointerAttachment.forPointers(quote.get().getAttachments()))); PointerAttachment.forPointers(quote.get().getAttachments()),
getMentions(quote.get().getMentions())));
} }
private Optional<Attachment> getStickerAttachment(Optional<SignalServiceDataMessage.Sticker> sticker) { private Optional<Attachment> getStickerAttachment(Optional<SignalServiceDataMessage.Sticker> sticker) {
@ -1685,6 +1708,26 @@ public final class PushProcessMessageJob extends BaseJob {
return Optional.of(linkPreviews); return Optional.of(linkPreviews);
} }
private Optional<List<Mention>> getMentions(Optional<List<SignalServiceDataMessage.Mention>> signalServiceMentions) {
if (!signalServiceMentions.isPresent()) return Optional.absent();
return Optional.of(getMentions(signalServiceMentions.get()));
}
private @NonNull List<Mention> getMentions(@Nullable List<SignalServiceDataMessage.Mention> signalServiceMentions) {
if (signalServiceMentions == null || signalServiceMentions.isEmpty()) {
return Collections.emptyList();
}
List<Mention> 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<InsertResult> insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp) { private Optional<InsertResult> insertPlaceholder(@NonNull String sender, int senderDevice, long timestamp) {
return insertPlaceholder(sender, senderDevice, timestamp, Optional.absent()); return insertPlaceholder(sender, senderDevice, timestamp, Optional.absent());
} }

View File

@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactModelMapper; import org.thoughtcrime.securesms.contactshare.ContactModelMapper;
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.PartProgressEvent; import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.Job;
@ -239,6 +240,7 @@ public abstract class PushSendJob extends SendJob {
long quoteId = message.getOutgoingQuote().getId(); long quoteId = message.getOutgoingQuote().getId();
String quoteBody = message.getOutgoingQuote().getText(); String quoteBody = message.getOutgoingQuote().getText();
RecipientId quoteAuthor = message.getOutgoingQuote().getAuthor(); RecipientId quoteAuthor = message.getOutgoingQuote().getAuthor();
List<SignalServiceDataMessage.Mention> quoteMentions = getMentionsFor(message.getOutgoingQuote().getMentions());
List<SignalServiceDataMessage.Quote.QuotedAttachment> quoteAttachments = new LinkedList<>(); List<SignalServiceDataMessage.Quote.QuotedAttachment> quoteAttachments = new LinkedList<>();
List<Attachment> filteredAttachments = Stream.of(message.getOutgoingQuote().getAttachments()) List<Attachment> filteredAttachments = Stream.of(message.getOutgoingQuote().getAttachments())
.filterNot(a -> MediaUtil.isViewOnceType(a.getContentType())) .filterNot(a -> MediaUtil.isViewOnceType(a.getContentType()))
@ -284,7 +286,7 @@ public abstract class PushSendJob extends SendJob {
Recipient quoteAuthorRecipient = Recipient.resolved(quoteAuthor); Recipient quoteAuthorRecipient = Recipient.resolved(quoteAuthor);
SignalServiceAddress quoteAddress = RecipientUtil.toSignalServiceAddress(context, quoteAuthorRecipient); 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<SignalServiceDataMessage.Sticker> getStickerFor(OutgoingMediaMessage message) { protected Optional<SignalServiceDataMessage.Sticker> getStickerFor(OutgoingMediaMessage message) {
@ -334,6 +336,12 @@ public abstract class PushSendJob extends SendJob {
}).toList(); }).toList();
} }
List<SignalServiceDataMessage.Mention> getMentionsFor(@NonNull List<Mention> 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 { protected void rotateSenderCertificateIfNecessary() throws IOException {
try { try {
byte[] certificateBytes = TextSecurePreferences.getUnidentifiedAccessCertificate(context); byte[] certificateBytes = TextSecurePreferences.getUnidentifiedAccessCertificate(context);

View File

@ -98,7 +98,7 @@ public final class WakeGroupV2Job extends BaseJob {
GroupDatabase.V2GroupProperties v2GroupProperties = group.get().requireV2GroupProperties(); GroupDatabase.V2GroupProperties v2GroupProperties = group.get().requireV2GroupProperties();
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(v2GroupProperties.getGroupMasterKey(), v2GroupProperties.getDecryptedGroup(), null, null); DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(v2GroupProperties.getGroupMasterKey(), v2GroupProperties.getDecryptedGroup(), null, null);
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context); 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); long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);

View File

@ -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);
}
}

View File

@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.keyvalue; package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.OnLifecycleEvent;
import androidx.preference.PreferenceDataStore; import androidx.preference.PreferenceDataStore;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@ -25,6 +24,7 @@ public final class SignalStore {
private final MiscellaneousValues misc; private final MiscellaneousValues misc;
private final InternalValues internalValues; private final InternalValues internalValues;
private final EmojiValues emojiValues; private final EmojiValues emojiValues;
private final NotificationSettings notificationSettings;
private SignalStore() { private SignalStore() {
this.store = ApplicationDependencies.getKeyValueStore(); this.store = ApplicationDependencies.getKeyValueStore();
@ -38,6 +38,7 @@ public final class SignalStore {
this.misc = new MiscellaneousValues(store); this.misc = new MiscellaneousValues(store);
this.internalValues = new InternalValues(store); this.internalValues = new InternalValues(store);
this.emojiValues = new EmojiValues(store); this.emojiValues = new EmojiValues(store);
this.notificationSettings = new NotificationSettings(store);
} }
public static void onFirstEverAppLaunch() { public static void onFirstEverAppLaunch() {
@ -50,6 +51,7 @@ public final class SignalStore {
tooltips().onFirstEverAppLaunch(); tooltips().onFirstEverAppLaunch();
misc().onFirstEverAppLaunch(); misc().onFirstEverAppLaunch();
internalValues().onFirstEverAppLaunch(); internalValues().onFirstEverAppLaunch();
notificationSettings().onFirstEverAppLaunch();
} }
public static @NonNull KbsValues kbsValues() { public static @NonNull KbsValues kbsValues() {
@ -92,6 +94,10 @@ public final class SignalStore {
return INSTANCE.emojiValues; return INSTANCE.emojiValues;
} }
public static @NonNull NotificationSettings notificationSettings() {
return INSTANCE.notificationSettings;
}
public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() { public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() {
return new GroupsV2AuthorizationSignalStoreCache(getStore()); return new GroupsV2AuthorizationSignalStoreCache(getStore());
} }

View File

@ -1,28 +1,33 @@
package org.thoughtcrime.securesms.longmessage; package org.thoughtcrime.securesms.longmessage;
import android.content.Context;
import android.text.TextUtils; 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; 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. * held in memory.
*/ */
class LongMessage { class LongMessage {
private final MessageRecord messageRecord; private final ConversationMessage conversationMessage;
private final String fullBody; private final String fullBody;
LongMessage(MessageRecord messageRecord, String fullBody) { LongMessage(@NonNull ConversationMessage conversationMessage, @NonNull String fullBody) {
this.messageRecord = messageRecord; this.conversationMessage = conversationMessage;
this.fullBody = fullBody; this.fullBody = fullBody;
} }
MessageRecord getMessageRecord() { @NonNull MessageRecord getMessageRecord() {
return messageRecord; return conversationMessage.getMessageRecord();
} }
String getFullBody() { @NonNull CharSequence getFullBody(@NonNull Context context) {
return !TextUtils.isEmpty(fullBody) ? fullBody : messageRecord.getBody(); return !TextUtils.isEmpty(fullBody) ? fullBody : conversationMessage.getDisplayBody(context);
} }
} }

View File

@ -147,7 +147,7 @@ public class LongMessageActivity extends PassphraseRequiredActivity {
TextView text = bubble.findViewById(R.id.longmessage_text); TextView text = bubble.findViewById(R.id.longmessage_text);
ConversationItemFooter footer = bubble.findViewById(R.id.longmessage_footer); 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)); SpannableString styledBody = linkifyMessageBody(new SpannableString(trimmedBody));
bubble.setVisibility(View.VISIBLE); 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 return text.length() <= MAX_DISPLAY_LENGTH ? text
: text.substring(0, MAX_DISPLAY_LENGTH); : text.subSequence(0, MAX_DISPLAY_LENGTH);
} }
private SpannableString linkifyMessageBody(SpannableString messageBody) { private SpannableString linkifyMessageBody(SpannableString messageBody) {

View File

@ -6,6 +6,8 @@ import android.net.Uri;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread; 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.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsDatabase; import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase; import org.thoughtcrime.securesms.database.SmsDatabase;
@ -38,7 +40,7 @@ class LongMessageRepository {
if (isMms) { if (isMms) {
callback.onComplete(getMmsLongMessage(context, mmsDatabase, messageId)); callback.onComplete(getMmsLongMessage(context, mmsDatabase, messageId));
} else { } else {
callback.onComplete(getSmsLongMessage(smsDatabase, messageId)); callback.onComplete(getSmsLongMessage(context, smsDatabase, messageId));
} }
}); });
} }
@ -51,9 +53,9 @@ class LongMessageRepository {
TextSlide textSlide = record.get().getSlideDeck().getTextSlide(); TextSlide textSlide = record.get().getSlideDeck().getTextSlide();
if (textSlide != null && textSlide.getUri() != null) { 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 { } else {
return Optional.of(new LongMessage(record.get(), "")); return Optional.of(new LongMessage(ConversationMessageFactory.createWithUnresolvedData(context, record.get()), ""));
} }
} else { } else {
return Optional.absent(); return Optional.absent();
@ -61,11 +63,11 @@ class LongMessageRepository {
} }
@WorkerThread @WorkerThread
private Optional<LongMessage> getSmsLongMessage(@NonNull SmsDatabase smsDatabase, long messageId) { private Optional<LongMessage> getSmsLongMessage(@NonNull Context context, @NonNull SmsDatabase smsDatabase, long messageId) {
Optional<MessageRecord> record = getSmsMessage(smsDatabase, messageId); Optional<MessageRecord> record = getSmsMessage(smsDatabase, messageId);
if (record.isPresent()) { if (record.isPresent()) {
return Optional.of(new LongMessage(record.get(), "")); return Optional.of(new LongMessage(ConversationMessageFactory.createWithUnresolvedData(context, record.get()), ""));
} else { } else {
return Optional.absent(); return Optional.absent();
} }

View File

@ -30,6 +30,9 @@ import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption; 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.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle; import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard; import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; 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.imageeditor.model.EditorModel;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter; 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.recipients.RecipientId;
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment; import org.thoughtcrime.securesms.scribbles.ImageEditorFragment;
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Function3; import org.thoughtcrime.securesms.util.Function3;
import org.thoughtcrime.securesms.util.IOFunction; import org.thoughtcrime.securesms.util.IOFunction;
import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MediaUtil;
@ -76,6 +82,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* Encompasses the entire flow of sending media, starting from the selection process to the actual * 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 EmojiEditText captionText;
private EmojiToggle emojiToggle; private EmojiToggle emojiToggle;
private Stub<MediaKeyboard> emojiDrawer; private Stub<MediaKeyboard> emojiDrawer;
private Stub<View> mentionSuggestions;
private TextView charactersLeft; private TextView charactersLeft;
private RecyclerView mediaRail; private RecyclerView mediaRail;
private MediaRailAdapter mediaRailAdapter; 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. * 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 intent = new Intent(context, MediaSendActivity.class);
intent.putExtra(KEY_RECIPIENT, recipient.getId()); intent.putExtra(KEY_RECIPIENT, recipient.getId());
intent.putExtra(KEY_TRANSPORT, transport); intent.putExtra(KEY_TRANSPORT, transport);
@ -174,7 +182,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
public static Intent buildEditorIntent(@NonNull Context context, public static Intent buildEditorIntent(@NonNull Context context,
@NonNull List<Media> media, @NonNull List<Media> media,
@NonNull Recipient recipient, @NonNull Recipient recipient,
@NonNull String body, @NonNull CharSequence body,
@NonNull TransportOption transport) @NonNull TransportOption transport)
{ {
Intent intent = buildGalleryIntent(context, recipient, body, 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); charactersLeft = findViewById(R.id.mediasend_characters_left);
mediaRail = findViewById(R.id.mediasend_media_rail); mediaRail = findViewById(R.id.mediasend_media_rail);
emojiDrawer = new Stub<>(findViewById(R.id.mediasend_emoji_drawer_stub)); 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); RecipientId recipientId = getIntent().getParcelableExtra(KEY_RECIPIENT);
if (recipientId != null) { if (recipientId != null) {
@ -222,7 +231,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
viewModel.setTransport(transport); viewModel.setTransport(transport);
viewModel.setRecipient(recipient != null ? recipient.get() : null); viewModel.setRecipient(recipient != null ? recipient.get() : null);
viewModel.onBodyChanged(getIntent().getStringExtra(KEY_BODY)); viewModel.onBodyChanged(getIntent().getCharSequenceExtra(KEY_BODY));
List<Media> media = getIntent().getParcelableArrayListExtra(KEY_MEDIA); List<Media> media = getIntent().getParcelableArrayListExtra(KEY_MEDIA);
boolean isCamera = getIntent().getBooleanExtra(KEY_IS_CAMERA, false); boolean isCamera = getIntent().getBooleanExtra(KEY_IS_CAMERA, false);
@ -309,6 +318,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
} }
initViewModel(); initViewModel();
if (FeatureFlags.mentions()) initializeMentionsViewModel();
revealButton.setOnClickListener(v -> viewModel.onRevealButtonToggled()); revealButton.setOnClickListener(v -> viewModel.onRevealButtonToggled());
continueButton.setOnClickListener(v -> navigateToContactSelect()); continueButton.setOnClickListener(v -> navigateToContactSelect());
@ -525,7 +535,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
MediaSendFragment fragment = getMediaSendFragment(); MediaSendFragment fragment = getMediaSendFragment();
if (fragment != null) { if (fragment != null) {
viewModel.onSendClicked(buildModelsToTransform(fragment), recipients).observe(this, result -> { viewModel.onSendClicked(buildModelsToTransform(fragment), recipients, composeText.getMentions()).observe(this, result -> {
finish(); finish();
}); });
} else { } else {
@ -546,7 +556,7 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
sendButton.setEnabled(false); 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<Media, MediaTransform> buildModelsToTransform(@NonNull MediaSendFragment fragment) { private static Map<Media, MediaTransform> 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<String> 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) { private void presentRecipient(@Nullable Recipient recipient) {
if (recipient == null) { if (recipient == null) {
composeText.setHint(R.string.MediaSendActivity_message); composeText.setHint(R.string.MediaSendActivity_message);
@ -836,9 +886,9 @@ public class MediaSendActivity extends PassphraseRequiredActivity implements Med
private void presentCharactersRemaining() { private void presentCharactersRemaining() {
String messageBody = composeText.getTextTrimmed(); String messageBody = composeText.getTextTrimmed().toString();
TransportOption transportOption = sendButton.getSelectedTransport(); TransportOption transportOption = sendButton.getSelectedTransport();
CharacterState characterState = transportOption.calculateCharacters(messageBody); CharacterState characterState = transportOption.calculateCharacters(messageBody);
if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) { if (characterState.charactersRemaining <= 15 || characterState.messagesSpent > 1) {
charactersLeft.setText(String.format(Locale.getDefault(), charactersLeft.setText(String.format(Locale.getDefault(),

View File

@ -7,6 +7,7 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult; import org.thoughtcrime.securesms.sms.MessageSender.PreUploadResult;
import org.thoughtcrime.securesms.util.ParcelUtil; import org.thoughtcrime.securesms.util.ParcelUtil;
import org.whispersystems.libsignal.util.guava.Preconditions; import org.whispersystems.libsignal.util.guava.Preconditions;
@ -24,36 +25,41 @@ public class MediaSendActivityResult implements Parcelable {
private final String body; private final String body;
private final TransportOption transport; private final TransportOption transport;
private final boolean viewOnce; private final boolean viewOnce;
private final Collection<Mention> mentions;
static @NonNull MediaSendActivityResult forPreUpload(@NonNull Collection<PreUploadResult> uploadResults, static @NonNull MediaSendActivityResult forPreUpload(@NonNull Collection<PreUploadResult> uploadResults,
@NonNull String body, @NonNull String body,
@NonNull TransportOption transport, @NonNull TransportOption transport,
boolean viewOnce) boolean viewOnce,
@NonNull List<Mention> mentions)
{ {
Preconditions.checkArgument(uploadResults.size() > 0, "Must supply uploadResults!"); 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<Media> nonUploadedMedia, static @NonNull MediaSendActivityResult forTraditionalSend(@NonNull List<Media> nonUploadedMedia,
@NonNull String body, @NonNull String body,
@NonNull TransportOption transport, @NonNull TransportOption transport,
boolean viewOnce) boolean viewOnce,
@NonNull List<Mention> mentions)
{ {
Preconditions.checkArgument(nonUploadedMedia.size() > 0, "Must supply media!"); 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<PreUploadResult> uploadResults, private MediaSendActivityResult(@NonNull Collection<PreUploadResult> uploadResults,
@NonNull List<Media> nonUploadedMedia, @NonNull List<Media> nonUploadedMedia,
@NonNull String body, @NonNull String body,
@NonNull TransportOption transport, @NonNull TransportOption transport,
boolean viewOnce) boolean viewOnce,
@NonNull List<Mention> mentions)
{ {
this.uploadResults = uploadResults; this.uploadResults = uploadResults;
this.nonUploadedMedia = nonUploadedMedia; this.nonUploadedMedia = nonUploadedMedia;
this.body = body; this.body = body;
this.transport = transport; this.transport = transport;
this.viewOnce = viewOnce; this.viewOnce = viewOnce;
this.mentions = mentions;
} }
private MediaSendActivityResult(Parcel in) { private MediaSendActivityResult(Parcel in) {
@ -62,6 +68,7 @@ public class MediaSendActivityResult implements Parcelable {
this.body = in.readString(); this.body = in.readString();
this.transport = in.readParcelable(TransportOption.class.getClassLoader()); this.transport = in.readParcelable(TransportOption.class.getClassLoader());
this.viewOnce = ParcelUtil.readBoolean(in); this.viewOnce = ParcelUtil.readBoolean(in);
this.mentions = ParcelUtil.readParcelableCollection(in, Mention.class);
} }
public boolean isPushPreUpload() { public boolean isPushPreUpload() {
@ -88,6 +95,10 @@ public class MediaSendActivityResult implements Parcelable {
return viewOnce; return viewOnce;
} }
public @NonNull Collection<Mention> getMentions() {
return mentions;
}
public static final Creator<MediaSendActivityResult> CREATOR = new Creator<MediaSendActivityResult>() { public static final Creator<MediaSendActivityResult> CREATOR = new Creator<MediaSendActivityResult>() {
@Override @Override
public MediaSendActivityResult createFromParcel(Parcel in) { public MediaSendActivityResult createFromParcel(Parcel in) {
@ -112,5 +123,6 @@ public class MediaSendActivityResult implements Parcelable {
dest.writeString(body); dest.writeString(body);
dest.writeParcelable(transport, 0); dest.writeParcelable(transport, 0);
ParcelUtil.writeBoolean(dest, viewOnce); ParcelUtil.writeBoolean(dest, viewOnce);
ParcelUtil.writeParcelableCollection(dest, mentions);
} }
} }

View File

@ -17,6 +17,7 @@ import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.TransportOption; import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.mms.MediaConstraints;
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage;
@ -453,7 +454,7 @@ class MediaSendViewModel extends ViewModel {
savedDrawState.putAll(state); savedDrawState.putAll(state);
} }
@NonNull LiveData<MediaSendActivityResult> onSendClicked(Map<Media, MediaTransform> modelsToTransform, @NonNull List<Recipient> recipients) { @NonNull LiveData<MediaSendActivityResult> onSendClicked(Map<Media, MediaTransform> modelsToTransform, @NonNull List<Recipient> recipients, @NonNull List<Mention> mentions) {
if (isSms && recipients.size() > 0) { if (isSms && recipients.size() > 0) {
throw new IllegalStateException("Provided recipients to send to, but this is SMS!"); 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)) { if (isSms || MessageSender.isLocalSelfSend(application, recipient, isSms)) {
Log.i(TAG, "SMS or local self-send. Skipping pre-upload."); 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; return;
} }
@ -493,12 +494,12 @@ class MediaSendViewModel extends ViewModel {
uploadRepository.updateDisplayOrder(updatedMedia); uploadRepository.updateDisplayOrder(updatedMedia);
uploadRepository.getPreUploadResults(uploadResults -> { uploadRepository.getPreUploadResults(uploadResults -> {
if (recipients.size() > 0) { if (recipients.size() > 0) {
sendMessages(recipients, splitBody, uploadResults); sendMessages(recipients, splitBody, uploadResults, mentions);
uploadRepository.deleteAbandonedAttachments(); uploadRepository.deleteAbandonedAttachments();
} }
Util.cancelRunnableOnMain(dialogRunnable); 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 @WorkerThread
private void sendMessages(@NonNull List<Recipient> recipients, @NonNull String body, @NonNull Collection<PreUploadResult> preUploadResults) { private void sendMessages(@NonNull List<Recipient> recipients, @NonNull String body, @NonNull Collection<PreUploadResult> preUploadResults, @NonNull List<Mention> mentions) {
List<OutgoingSecureMediaMessage> messages = new ArrayList<>(recipients.size()); List<OutgoingSecureMediaMessage> messages = new ArrayList<>(recipients.size());
for (Recipient recipient : recipients) { for (Recipient recipient : recipients) {
@ -647,6 +648,7 @@ class MediaSendViewModel extends ViewModel {
null, null,
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(),
mentions,
Collections.emptyList(), Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());

View File

@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
import com.annimon.stream.ComparatorCompat; import com.annimon.stream.ComparatorCompat;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@ -17,7 +18,7 @@ final class MessageDetails {
private static final Comparator<RecipientDeliveryStatus> ALPHABETICAL = (r1, r2) -> r1.getRecipient().getDisplayName(ApplicationDependencies.getApplication()).compareToIgnoreCase(r2.getRecipient().getDisplayName(ApplicationDependencies.getApplication())); private static final Comparator<RecipientDeliveryStatus> ALPHABETICAL = (r1, r2) -> r1.getRecipient().getDisplayName(ApplicationDependencies.getApplication()).compareToIgnoreCase(r2.getRecipient().getDisplayName(ApplicationDependencies.getApplication()));
private static final Comparator<RecipientDeliveryStatus> RECIPIENT_COMPARATOR = ComparatorCompat.chain(HAS_DISPLAY_NAME).thenComparing(ALPHABETICAL); private static final Comparator<RecipientDeliveryStatus> RECIPIENT_COMPARATOR = ComparatorCompat.chain(HAS_DISPLAY_NAME).thenComparing(ALPHABETICAL);
private final MessageRecord messageRecord; private final ConversationMessage conversationMessage;
private final Collection<RecipientDeliveryStatus> pending; private final Collection<RecipientDeliveryStatus> pending;
private final Collection<RecipientDeliveryStatus> sent; private final Collection<RecipientDeliveryStatus> sent;
@ -25,8 +26,8 @@ final class MessageDetails {
private final Collection<RecipientDeliveryStatus> read; private final Collection<RecipientDeliveryStatus> read;
private final Collection<RecipientDeliveryStatus> notSent; private final Collection<RecipientDeliveryStatus> notSent;
MessageDetails(MessageRecord messageRecord, List<RecipientDeliveryStatus> recipients) { MessageDetails(@NonNull ConversationMessage conversationMessage, @NonNull List<RecipientDeliveryStatus> recipients) {
this.messageRecord = messageRecord; this.conversationMessage = conversationMessage;
pending = new TreeSet<>(RECIPIENT_COMPARATOR); pending = new TreeSet<>(RECIPIENT_COMPARATOR);
sent = new TreeSet<>(RECIPIENT_COMPARATOR); sent = new TreeSet<>(RECIPIENT_COMPARATOR);
@ -34,7 +35,7 @@ final class MessageDetails {
read = new TreeSet<>(RECIPIENT_COMPARATOR); read = new TreeSet<>(RECIPIENT_COMPARATOR);
notSent = new TreeSet<>(RECIPIENT_COMPARATOR); notSent = new TreeSet<>(RECIPIENT_COMPARATOR);
if (messageRecord.isOutgoing()) { if (conversationMessage.getMessageRecord().isOutgoing()) {
for (RecipientDeliveryStatus status : recipients) { for (RecipientDeliveryStatus status : recipients) {
switch (status.getDeliveryStatus()) { switch (status.getDeliveryStatus()) {
case UNKNOWN: case UNKNOWN:
@ -59,8 +60,8 @@ final class MessageDetails {
} }
} }
@NonNull MessageRecord getMessageRecord() { @NonNull ConversationMessage getConversationMessage() {
return messageRecord; return conversationMessage;
} }
@NonNull Collection<RecipientDeliveryStatus> getPending() { @NonNull Collection<RecipientDeliveryStatus> getPending() {

View File

@ -124,7 +124,7 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity {
assert getSupportActionBar() != null; assert getSupportActionBar() != null;
getSupportActionBar().setBackgroundDrawable(new ColorDrawable(color.toActionBarColor(this))); 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)); getWindow().setStatusBarColor(color.toStatusBarColor(this));
} }
} }
@ -132,9 +132,9 @@ public final class MessageDetailsActivity extends PassphraseRequiredActivity {
private List<MessageDetailsViewState<?>> convertToRows(MessageDetails details) { private List<MessageDetailsViewState<?>> convertToRows(MessageDetails details) {
List<MessageDetailsViewState<?>> list = new ArrayList<>(); List<MessageDetailsViewState<?>> 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.NOT_SENT, details.getNotSent());
addRecipients(list, RecipientHeader.READ, details.getRead()); addRecipients(list, RecipientHeader.READ, details.getRead());
addRecipients(list, RecipientHeader.DELIVERED, details.getDelivered()); addRecipients(list, RecipientHeader.DELIVERED, details.getDelivered());

View File

@ -10,6 +10,7 @@ import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
@ -45,7 +46,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
@Override @Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (holder instanceof MessageHeaderViewHolder) { if (holder instanceof MessageHeaderViewHolder) {
((MessageHeaderViewHolder) holder).bind((MessageRecord) getItem(position).data, running); ((MessageHeaderViewHolder) holder).bind((ConversationMessage) getItem(position).data, running);
} else if (holder instanceof RecipientHeaderViewHolder) { } else if (holder instanceof RecipientHeaderViewHolder) {
((RecipientHeaderViewHolder) holder).bind((RecipientHeader) getItem(position).data); ((RecipientHeaderViewHolder) holder).bind((RecipientHeader) getItem(position).data);
} else if (holder instanceof RecipientViewHolder) { } else if (holder instanceof RecipientViewHolder) {
@ -60,7 +61,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
if (payloads.isEmpty()) { if (payloads.isEmpty()) {
super.onBindViewHolder(holder, position, payloads); super.onBindViewHolder(holder, position, payloads);
} else if (holder instanceof MessageHeaderViewHolder) { } else if (holder instanceof MessageHeaderViewHolder) {
((MessageHeaderViewHolder) holder).partialBind((MessageRecord) getItem(position).data, running); ((MessageHeaderViewHolder) holder).partialBind((ConversationMessage) getItem(position).data, running);
} }
} }

View File

@ -8,11 +8,14 @@ import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -86,7 +89,7 @@ final class MessageDetailsRepository {
} }
} }
return new MessageDetails(messageRecord, recipients); return new MessageDetails(ConversationMessageFactory.createWithUnresolvedData(context, messageRecord), recipients);
} }
private @Nullable NetworkFailure getNetworkFailure(MessageRecord messageRecord, Recipient recipient) { private @Nullable NetworkFailure getNetworkFailure(MessageRecord messageRecord, Recipient recipient) {

View File

@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationItem; import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.MessageSender;
@ -63,25 +64,30 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder {
receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia); receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia);
} }
void bind(MessageRecord messageRecord, boolean running) { void bind(ConversationMessage conversationMessage, boolean running) {
bindMessageView(messageRecord); MessageRecord messageRecord = conversationMessage.getMessageRecord();
bindMessageView(conversationMessage);
bindErrorState(messageRecord); bindErrorState(messageRecord);
bindSentReceivedDates(messageRecord); bindSentReceivedDates(messageRecord);
bindExpirationTime(messageRecord, running); bindExpirationTime(messageRecord, running);
bindTransport(messageRecord); bindTransport(messageRecord);
} }
void partialBind(MessageRecord messageRecord, boolean running) { void partialBind(ConversationMessage conversationMessage, boolean running) {
bindExpirationTime(messageRecord, running); bindExpirationTime(conversationMessage.getMessageRecord(), running);
} }
private void bindMessageView(MessageRecord messageRecord) { private void bindMessageView(ConversationMessage conversationMessage) {
if (conversationItem == null) { if (conversationItem == null) {
if (messageRecord.isGroupAction()) conversationItem = (ConversationItem) updateStub.inflate(); if (conversationMessage.getMessageRecord().isGroupAction()) {
else if (messageRecord.isOutgoing()) conversationItem = (ConversationItem) sentStub.inflate(); conversationItem = (ConversationItem) updateStub.inflate();
else conversationItem = (ConversationItem) receivedStub.inflate(); } else if (conversationMessage.getMessageRecord().isOutgoing()) {
conversationItem = (ConversationItem) sentStub.inflate();
} else {
conversationItem = (ConversationItem) receivedStub.inflate();
}
} }
conversationItem.bind(new ConversationMessage(messageRecord), Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), 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) { private void bindErrorState(MessageRecord messageRecord) {

View File

@ -377,7 +377,7 @@ public class AttachmentManager {
.execute(); .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) Permissions.with(activity)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary() .ifNecessary()

View File

@ -5,6 +5,7 @@ import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.PointerAttachment; import org.thoughtcrime.securesms.attachments.PointerAttachment;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.groups.GroupId; import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
@ -35,6 +36,7 @@ public class IncomingMediaMessage {
private final List<Attachment> attachments = new LinkedList<>(); private final List<Attachment> attachments = new LinkedList<>();
private final List<Contact> sharedContacts = new LinkedList<>(); private final List<Contact> sharedContacts = new LinkedList<>();
private final List<LinkPreview> linkPreviews = new LinkedList<>(); private final List<LinkPreview> linkPreviews = new LinkedList<>();
private final List<Mention> mentions = new LinkedList<>();
public IncomingMediaMessage(@NonNull RecipientId from, public IncomingMediaMessage(@NonNull RecipientId from,
Optional<GroupId> groupId, Optional<GroupId> groupId,
@ -78,6 +80,7 @@ public class IncomingMediaMessage {
Optional<QuoteModel> quote, Optional<QuoteModel> quote,
Optional<List<Contact>> sharedContacts, Optional<List<Contact>> sharedContacts,
Optional<List<LinkPreview>> linkPreviews, Optional<List<LinkPreview>> linkPreviews,
Optional<List<Mention>> mentions,
Optional<Attachment> sticker) Optional<Attachment> sticker)
{ {
this.push = true; this.push = true;
@ -98,6 +101,7 @@ public class IncomingMediaMessage {
this.attachments.addAll(PointerAttachment.forPointers(attachments)); this.attachments.addAll(PointerAttachment.forPointers(attachments));
this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList())); this.sharedContacts.addAll(sharedContacts.or(Collections.emptyList()));
this.linkPreviews.addAll(linkPreviews.or(Collections.emptyList())); this.linkPreviews.addAll(linkPreviews.or(Collections.emptyList()));
this.mentions.addAll(mentions.or(Collections.emptyList()));
if (sticker.isPresent()) { if (sticker.isPresent()) {
this.attachments.add(sticker.get()); this.attachments.add(sticker.get());
@ -164,6 +168,10 @@ public class IncomingMediaMessage {
return linkPreviews; return linkPreviews;
} }
public @NonNull List<Mention> getMentions() {
return mentions;
}
public boolean isUnidentified() { public boolean isUnidentified() {
return unidentified; return unidentified;
} }

View File

@ -12,7 +12,7 @@ public class OutgoingExpirationUpdateMessage extends OutgoingSecureMediaMessage
public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn) { public OutgoingExpirationUpdateMessage(Recipient recipient, long sentTimeMillis, long expiresIn) {
super(recipient, "", new LinkedList<Attachment>(), sentTimeMillis, super(recipient, "", new LinkedList<Attachment>(), sentTimeMillis,
ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, false, null, Collections.emptyList(), ThreadDatabase.DistributionTypes.CONVERSATION, expiresIn, false, null, Collections.emptyList(),
Collections.emptyList()); Collections.emptyList(), Collections.emptyList());
} }
@Override @Override

View File

@ -6,6 +6,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.ThreadDatabase; 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.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -26,10 +27,11 @@ public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage
boolean viewOnce, boolean viewOnce,
@Nullable QuoteModel quote, @Nullable QuoteModel quote,
@NonNull List<Contact> contacts, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> previews) @NonNull List<LinkPreview> previews,
@NonNull List<Mention> mentions)
{ {
super(recipient, groupContext.getEncodedGroupContext(), avatar, sentTimeMillis, 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; this.messageGroupContext = groupContext;
} }
@ -42,9 +44,10 @@ public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage
boolean viewOnce, boolean viewOnce,
@Nullable QuoteModel quote, @Nullable QuoteModel quote,
@NonNull List<Contact> contacts, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> previews) @NonNull List<LinkPreview> previews,
@NonNull List<Mention> 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, public OutgoingGroupUpdateMessage(@NonNull Recipient recipient,
@ -55,9 +58,10 @@ public final class OutgoingGroupUpdateMessage extends OutgoingSecureMediaMessage
boolean viewOnce, boolean viewOnce,
@Nullable QuoteModel quote, @Nullable QuoteModel quote,
@NonNull List<Contact> contacts, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> previews) @NonNull List<LinkPreview> previews,
@NonNull List<Mention> 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 @Override

View File

@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -30,6 +31,7 @@ public class OutgoingMediaMessage {
private final List<IdentityKeyMismatch> identityKeyMismatches = new LinkedList<>(); private final List<IdentityKeyMismatch> identityKeyMismatches = new LinkedList<>();
private final List<Contact> contacts = new LinkedList<>(); private final List<Contact> contacts = new LinkedList<>();
private final List<LinkPreview> linkPreviews = new LinkedList<>(); private final List<LinkPreview> linkPreviews = new LinkedList<>();
private final List<Mention> mentions = new LinkedList<>();
public OutgoingMediaMessage(Recipient recipient, String message, public OutgoingMediaMessage(Recipient recipient, String message,
List<Attachment> attachments, long sentTimeMillis, List<Attachment> attachments, long sentTimeMillis,
@ -38,6 +40,7 @@ public class OutgoingMediaMessage {
@Nullable QuoteModel outgoingQuote, @Nullable QuoteModel outgoingQuote,
@NonNull List<Contact> contacts, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews, @NonNull List<LinkPreview> linkPreviews,
@NonNull List<Mention> mentions,
@NonNull List<NetworkFailure> networkFailures, @NonNull List<NetworkFailure> networkFailures,
@NonNull List<IdentityKeyMismatch> identityKeyMismatches) @NonNull List<IdentityKeyMismatch> identityKeyMismatches)
{ {
@ -53,6 +56,7 @@ public class OutgoingMediaMessage {
this.contacts.addAll(contacts); this.contacts.addAll(contacts);
this.linkPreviews.addAll(linkPreviews); this.linkPreviews.addAll(linkPreviews);
this.mentions.addAll(mentions);
this.networkFailures.addAll(networkFailures); this.networkFailures.addAll(networkFailures);
this.identityKeyMismatches.addAll(identityKeyMismatches); this.identityKeyMismatches.addAll(identityKeyMismatches);
} }
@ -62,14 +66,15 @@ public class OutgoingMediaMessage {
boolean viewOnce, int distributionType, boolean viewOnce, int distributionType,
@Nullable QuoteModel outgoingQuote, @Nullable QuoteModel outgoingQuote,
@NonNull List<Contact> contacts, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> linkPreviews) @NonNull List<LinkPreview> linkPreviews,
@NonNull List<Mention> mentions)
{ {
this(recipient, this(recipient,
buildMessage(slideDeck, message), buildMessage(slideDeck, message),
slideDeck.asAttachments(), slideDeck.asAttachments(),
sentTimeMillis, subscriptionId, sentTimeMillis, subscriptionId,
expiresIn, viewOnce, distributionType, outgoingQuote, expiresIn, viewOnce, distributionType, outgoingQuote,
contacts, linkPreviews, new LinkedList<>(), new LinkedList<>()); contacts, linkPreviews, mentions, new LinkedList<>(), new LinkedList<>());
} }
public OutgoingMediaMessage(OutgoingMediaMessage that) { public OutgoingMediaMessage(OutgoingMediaMessage that) {
@ -87,6 +92,7 @@ public class OutgoingMediaMessage {
this.networkFailures.addAll(that.networkFailures); this.networkFailures.addAll(that.networkFailures);
this.contacts.addAll(that.contacts); this.contacts.addAll(that.contacts);
this.linkPreviews.addAll(that.linkPreviews); this.linkPreviews.addAll(that.linkPreviews);
this.mentions.addAll(that.mentions);
} }
public Recipient getRecipient() { public Recipient getRecipient() {
@ -145,6 +151,10 @@ public class OutgoingMediaMessage {
return linkPreviews; return linkPreviews;
} }
public @NonNull List<Mention> getMentions() {
return mentions;
}
public @NonNull List<NetworkFailure> getNetworkFailures() { public @NonNull List<NetworkFailure> getNetworkFailures() {
return networkFailures; return networkFailures;
} }

View File

@ -5,6 +5,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.linkpreview.LinkPreview; import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
@ -21,9 +22,10 @@ public class OutgoingSecureMediaMessage extends OutgoingMediaMessage {
boolean viewOnce, boolean viewOnce,
@Nullable QuoteModel quote, @Nullable QuoteModel quote,
@NonNull List<Contact> contacts, @NonNull List<Contact> contacts,
@NonNull List<LinkPreview> previews) @NonNull List<LinkPreview> previews,
@NonNull List<Mention> 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) { public OutgoingSecureMediaMessage(OutgoingMediaMessage base) {

View File

@ -5,8 +5,10 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.attachments.Attachment; import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Collections;
import java.util.List; import java.util.List;
public class QuoteModel { public class QuoteModel {
@ -16,13 +18,15 @@ public class QuoteModel {
private final String text; private final String text;
private final boolean missing; private final boolean missing;
private final List<Attachment> attachments; private final List<Attachment> attachments;
private final List<Mention> mentions;
public QuoteModel(long id, @NonNull RecipientId author, String text, boolean missing, @Nullable List<Attachment> attachments) { public QuoteModel(long id, @NonNull RecipientId author, String text, boolean missing, @Nullable List<Attachment> attachments, @Nullable List<Mention> mentions) {
this.id = id; this.id = id;
this.author = author; this.author = author;
this.text = text; this.text = text;
this.missing = missing; this.missing = missing;
this.attachments = attachments; this.attachments = attachments;
this.mentions = mentions != null ? mentions : Collections.emptyList();
} }
public long getId() { public long getId() {
@ -44,4 +48,8 @@ public class QuoteModel {
public List<Attachment> getAttachments() { public List<Attachment> getAttachments() {
return attachments; return attachments;
} }
public @NonNull List<Mention> getMentions() {
return mentions;
}
} }

View File

@ -75,7 +75,20 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver {
if (recipient.resolve().isGroup()) { if (recipient.resolve().isGroup()) {
Log.w(TAG, "GroupRecipient, Sending media message"); 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); replyThreadId = MessageSender.send(context, reply, threadId, false, null);
} else { } else {
Log.w(TAG, "Sending regular message "); Log.w(TAG, "Sending regular message ");

View File

@ -43,15 +43,18 @@ import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo; import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
import org.thoughtcrime.securesms.database.MmsSmsColumns; import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadBodyUtil; import org.thoughtcrime.securesms.database.ThreadBodyUtil;
import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord; import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver; import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.mms.Slide; 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.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.ServiceUtil;
@ -243,18 +247,14 @@ public class DefaultMessageNotifier implements MessageNotifier {
{ {
boolean isVisible = visibleThread == threadId; boolean isVisible = visibleThread == threadId;
ThreadDatabase threads = DatabaseFactory.getThreadDatabase(context); ThreadDatabase threads = DatabaseFactory.getThreadDatabase(context);
Recipient recipients = DatabaseFactory.getThreadDatabase(context)
.getRecipientForThreadId(threadId);
if (isVisible) { if (isVisible) {
List<MarkedMessageInfo> messageIds = threads.setRead(threadId, false); List<MarkedMessageInfo> messageIds = threads.setRead(threadId, false);
MarkReadReceiver.process(context, messageIds); MarkReadReceiver.process(context, messageIds);
} }
if (!TextSecurePreferences.isNotificationsEnabled(context) || if (!TextSecurePreferences.isNotificationsEnabled(context)) {
(recipients != null && recipients.isMuted()))
{
return; return;
} }
@ -499,7 +499,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
Recipient recipient = record.getIndividualRecipient().resolve(); Recipient recipient = record.getIndividualRecipient().resolve();
Recipient conversationRecipient = record.getRecipient().resolve(); Recipient conversationRecipient = record.getRecipient().resolve();
long threadId = record.getThreadId(); long threadId = record.getThreadId();
CharSequence body = record.getDisplayBody(context); CharSequence body = MentionUtil.updateBodyWithDisplayNames(context, record);
Recipient threadRecipients = null; Recipient threadRecipients = null;
SlideDeck slideDeck = null; SlideDeck slideDeck = null;
long timestamp = record.getTimestamp(); long timestamp = record.getTimestamp();
@ -527,7 +527,17 @@ public class DefaultMessageNotifier implements MessageNotifier {
slideDeck = ((MmsMessageRecord) record).getSlideDeck(); 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)); notificationState.addNotification(new NotificationItem(id, mms, recipient, conversationRecipient, threadRecipients, threadId, body, timestamp, receivedTimestamp, slideDeck, false));
} }
} }

View File

@ -77,7 +77,20 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
switch (replyMethod) { switch (replyMethod) {
case GroupMessage: { 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); threadId = MessageSender.send(context, reply, -1, false, null);
break; break;
} }

View File

@ -12,13 +12,18 @@ import android.provider.Settings;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.preference.ListPreference; import androidx.preference.ListPreference;
import androidx.preference.Preference; import androidx.preference.Preference;
import androidx.preference.PreferenceDataStore;
import android.text.TextUtils; import android.text.TextUtils;
import org.thoughtcrime.securesms.ApplicationPreferencesActivity; import org.thoughtcrime.securesms.ApplicationPreferencesActivity;
import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.SwitchPreferenceCompat; import org.thoughtcrime.securesms.components.SwitchPreferenceCompat;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; 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.notifications.NotificationChannels;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.TextSecurePreferences;
import static android.app.Activity.RESULT_OK; import static android.app.Activity.RESULT_OK;
@ -115,11 +120,18 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
initializeCallRingtoneSummary(findPreference(TextSecurePreferences.CALL_RINGTONE_PREF)); initializeCallRingtoneSummary(findPreference(TextSecurePreferences.CALL_RINGTONE_PREF));
initializeMessageVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.VIBRATE_PREF)); initializeMessageVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.VIBRATE_PREF));
initializeCallVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_VIBRATE_PREF)); initializeCallVibrateSummary((SwitchPreferenceCompat)findPreference(TextSecurePreferences.CALL_VIBRATE_PREF));
if (FeatureFlags.mentions()) {
initializeMentionsNotifyMeSummary((SwitchPreferenceCompat)findPreference(NotificationSettings.MENTIONS_NOTIFY_ME));
}
} }
@Override @Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) { public void onCreatePreferences(@Nullable Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.preferences_notifications); addPreferencesFromResource(R.xml.preferences_notifications);
if (FeatureFlags.mentions()) {
addPreferencesFromResource(R.xml.preferences_notifications_mentions);
}
} }
@Override @Override
@ -197,6 +209,11 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
pref.setChecked(TextSecurePreferences.isCallNotificationVibrateEnabled(getContext())); 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) { public static CharSequence getSummary(Context context) {
final int onCapsResId = R.string.ApplicationPreferencesActivity_On; final int onCapsResId = R.string.ApplicationPreferencesActivity_On;
final int offCapsResId = R.string.ApplicationPreferencesActivity_Off; final int offCapsResId = R.string.ApplicationPreferencesActivity_Off;

View File

@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.contacts.avatars.TransparentContactPhoto;
import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.RecipientDatabase; 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.RegisteredState;
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState;
@ -102,6 +103,7 @@ public class Recipient {
private final byte[] storageId; private final byte[] storageId;
private final byte[] identityKey; private final byte[] identityKey;
private final VerifiedStatus identityStatus; private final VerifiedStatus identityStatus;
private final MentionSetting mentionSetting;
/** /**
@ -316,6 +318,7 @@ public class Recipient {
this.storageId = null; this.storageId = null;
this.identityKey = null; this.identityKey = null;
this.identityStatus = VerifiedStatus.DEFAULT; this.identityStatus = VerifiedStatus.DEFAULT;
this.mentionSetting = MentionSetting.GLOBAL;
} }
public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) { public Recipient(@NonNull RecipientId id, @NonNull RecipientDetails details, boolean resolved) {
@ -359,6 +362,7 @@ public class Recipient {
this.storageId = details.storageId; this.storageId = details.storageId;
this.identityKey = details.identityKey; this.identityKey = details.identityKey;
this.identityStatus = details.identityStatus; this.identityStatus = details.identityStatus;
this.mentionSetting = details.mentionSetting;
} }
public @NonNull RecipientId getId() { public @NonNull RecipientId getId() {
@ -809,6 +813,10 @@ public class Recipient {
} }
} }
public @NonNull MentionSetting getMentionSetting() {
return mentionSetting;
}
@Override @Override
public boolean equals(Object o) { public boolean equals(Object o) {
if (this == o) return true; if (this == o) return true;

View File

@ -9,7 +9,9 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; 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.InsightsBannerTier;
import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting;
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings; import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientSettings;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode; import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
@ -66,6 +68,7 @@ public class RecipientDetails {
final byte[] storageId; final byte[] storageId;
final byte[] identityKey; final byte[] identityKey;
final VerifiedStatus identityStatus; final VerifiedStatus identityStatus;
final MentionSetting mentionSetting;
public RecipientDetails(@Nullable String name, public RecipientDetails(@Nullable String name,
@NonNull Optional<Long> groupAvatarId, @NonNull Optional<Long> groupAvatarId,
@ -112,6 +115,7 @@ public class RecipientDetails {
this.storageId = settings.getStorageId(); this.storageId = settings.getStorageId();
this.identityKey = settings.getIdentityKey(); this.identityKey = settings.getIdentityKey();
this.identityStatus = settings.getIdentityStatus(); this.identityStatus = settings.getIdentityStatus();
this.mentionSetting = settings.getMentionSetting();
if (name == null) this.name = settings.getSystemDisplayName(); if (name == null) this.name = settings.getSystemDisplayName();
else this.name = name; else this.name = name;
@ -160,6 +164,7 @@ public class RecipientDetails {
this.storageId = null; this.storageId = null;
this.identityKey = null; this.identityKey = null;
this.identityStatus = VerifiedStatus.DEFAULT; this.identityStatus = VerifiedStatus.DEFAULT;
this.mentionSetting = MentionSetting.GLOBAL;
} }
public static @NonNull RecipientDetails forIndividual(@NonNull Context context, @NonNull RecipientSettings settings) { public static @NonNull RecipientDetails forIndividual(@NonNull Context context, @NonNull RecipientSettings settings) {

View File

@ -296,7 +296,7 @@ public class ShareActivity extends PassphraseRequiredActivity
private void openConversation(long threadId, @NonNull RecipientId recipientId, @Nullable ShareData shareData) { private void openConversation(long threadId, @NonNull RecipientId recipientId, @Nullable ShareData shareData) {
Intent intent = new Intent(this, ConversationActivity.class); Intent intent = new Intent(this, ConversationActivity.class);
String textExtra = getIntent().getStringExtra(Intent.EXTRA_TEXT); CharSequence textExtra = getIntent().getCharSequenceExtra(Intent.EXTRA_TEXT);
ArrayList<Media> mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA); ArrayList<Media> mediaExtra = getIntent().getParcelableArrayListExtra(ConversationActivity.MEDIA_EXTRA);
StickerLocator stickerExtra = getIntent().getParcelableExtra(ConversationActivity.STICKER_EXTRA); StickerLocator stickerExtra = getIntent().getParcelableExtra(ConversationActivity.STICKER_EXTRA);
boolean borderlessExtra = getIntent().getBooleanExtra(ConversationActivity.BORDERLESS_EXTRA, false); boolean borderlessExtra = getIntent().getBooleanExtra(ConversationActivity.BORDERLESS_EXTRA, false);

View File

@ -180,4 +180,21 @@ public final class StringUtil {
.appendCodePoint(Bidi.PDI) .appendCodePoint(Bidi.PDI)
.toString(); .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;
}
} }

View File

@ -176,6 +176,10 @@ public class Util {
return value != null ? value : ""; return value != null ? value : "";
} }
public static @NonNull CharSequence emptyIfNull(@Nullable CharSequence value) {
return value != null ? value : "";
}
public static <E> List<List<E>> chunk(@NonNull List<E> list, int chunkSize) { public static <E> List<List<E>> chunk(@NonNull List<E> list, int chunkSize) {
List<List<E>> chunks = new ArrayList<>(list.size() / chunkSize); List<List<E>> chunks = new ArrayList<>(list.size() / chunkSize);

View File

@ -55,3 +55,16 @@ message ProfileChangeDetails {
StringChange profileNameChange = 1; StringChange profileNameChange = 1;
} }
message BodyRangeList {
message BodyRange {
int32 start = 1;
int32 length = 2;
oneof associatedValue {
string mentionUuid = 3;
}
}
repeated BodyRange ranges = 1;
}

View File

@ -290,6 +290,46 @@
</LinearLayout> </LinearLayout>
<LinearLayout
android:id="@+id/group_mentions_row"
android:layout_width="match_parent"
android:layout_height="@dimen/group_manage_fragment_row_height"
android:background="?selectableItemBackground"
android:clickable="true"
android:focusable="true">
<TextView
android:id="@+id/group_mentions"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="start|center_vertical"
android:paddingStart="@dimen/group_manage_fragment_row_horizontal_padding"
android:paddingEnd="@dimen/group_manage_fragment_row_horizontal_padding"
android:text="@string/ManageGroupActivity_mentions"
android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/group_mute_notifications_switch"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/group_mentions_value"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical|end"
android:paddingStart="@dimen/group_manage_fragment_row_horizontal_padding"
android:paddingEnd="@dimen/group_manage_fragment_row_horizontal_padding"
android:textAppearance="@style/Signal.Text.Body"
android:textColor="@color/ultramarine_text_button"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/group_custom_notifications"
app:layout_constraintTop_toBottomOf="@id/group_mute_notifications_switch"
tools:text="Default (Notify me)" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</androidx.cardview.widget.CardView> </androidx.cardview.widget.CardView>

View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<TextView
style="@style/TextAppearance.AppCompat.Subhead"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
android:paddingLeft="?attr/dialogPreferredPadding"
android:paddingRight="?attr/dialogPreferredPadding"
android:text="@string/GroupMentionSettingDialog_receive_notifications_when_youre_mentioned_in_muted_chats" />
<CheckedTextView
android:id="@+id/group_mention_setting_default"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:drawableStart="?android:attr/listChoiceIndicatorSingle"
android:drawablePadding="20dp"
android:gravity="center_vertical"
android:minHeight="?attr/listPreferredItemHeightSmall"
android:paddingStart="20dp"
android:paddingEnd="?attr/dialogPreferredPadding"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?attr/textColorAlertDialogListItem"
tools:text="@string/GroupMentionSettingDialog_default_notify_me" />
<CheckedTextView
android:id="@+id/group_mention_setting_always_notify"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:drawableStart="?android:attr/listChoiceIndicatorSingle"
android:drawablePadding="20dp"
android:gravity="center_vertical"
android:minHeight="?attr/listPreferredItemHeightSmall"
android:paddingStart="20dp"
android:paddingEnd="?attr/dialogPreferredPadding"
android:text="@string/GroupMentionSettingDialog_always_notify_me"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?attr/textColorAlertDialogListItem" />
<CheckedTextView
android:id="@+id/group_mention_setting_dont_notify"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:drawableStart="?android:attr/listChoiceIndicatorSingle"
android:drawablePadding="20dp"
android:gravity="center_vertical"
android:minHeight="?attr/listPreferredItemHeightSmall"
android:paddingStart="20dp"
android:paddingEnd="?attr/dialogPreferredPadding"
android:text="@string/GroupMentionSettingDialog_dont_notify_me"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?attr/textColorAlertDialogListItem" />
</LinearLayout>

View File

@ -27,6 +27,13 @@
android:clickable="true" android:clickable="true"
android:background="@color/transparent_black_40"> android:background="@color/transparent_black_40">
<ViewStub
android:id="@+id/mediasend_mention_suggestions_stub"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout="@layout/conversation_mention_suggestions_stub"/>
<org.thoughtcrime.securesms.components.emoji.EmojiEditText <org.thoughtcrime.securesms.components.emoji.EmojiEditText
android:id="@+id/mediasend_caption" android:id="@+id/mediasend_caption"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -117,7 +124,7 @@
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:background="@drawable/circle_tintable" android:background="@drawable/circle_tintable"
tools:backgroundTint="@color/core_blue"> tools:backgroundTint="@color/core_ultramarine">
<org.thoughtcrime.securesms.components.SendButton <org.thoughtcrime.securesms.components.SendButton
android:id="@+id/mediasend_send_button" android:id="@+id/mediasend_send_button"

View File

@ -16,7 +16,7 @@
<View <View
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:background="@color/core_grey_65"/> android:background="?conversation_mention_divider_color"/>
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/mentions_picker_list" android:id="@+id/mentions_picker_list"

View File

@ -3,11 +3,12 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="12dp" android:background="?selectableItemBackground"
android:minHeight="52dp"
android:orientation="horizontal"
android:gravity="center_vertical" android:gravity="center_vertical"
android:background="?selectableItemBackground"> android:paddingStart="12dp"
android:paddingTop="10dp"
android:paddingEnd="8dp"
android:paddingBottom="10dp">
<org.thoughtcrime.securesms.components.AvatarImageView <org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/mention_recipient_avatar" android:id="@+id/mention_recipient_avatar"
@ -17,12 +18,25 @@
<TextView <TextView
android:id="@+id/mention_recipient_name" android:id="@+id/mention_recipient_name"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp" android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:singleLine="true"
android:textAlignment="viewStart" android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.Preview" android:textAppearance="@style/Signal.Text.Preview"
tools:text="@tools:sample/full_names" /> tools:text="@tools:sample/full_names" />
<TextView
android:id="@+id/mention_recipient_username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="true"
android:textAlignment="viewStart"
android:textAppearance="@style/Signal.Text.Preview"
android:textColor="@color/core_grey_60"
tools:text="@tools:sample/last_names" />
</LinearLayout> </LinearLayout>

View File

@ -98,6 +98,7 @@
style="@style/Signal.Text.Body" style="@style/Signal.Text.Body"
android:ellipsize="end" android:ellipsize="end"
android:maxLines="2" android:maxLines="2"
app:emoji_renderMentions="false"
tools:text="With great power comes great responsibility." tools:text="With great power comes great responsibility."
tools:visibility="visible" /> tools:visibility="visible" />

View File

@ -90,6 +90,7 @@
<attr name="conversation_title_color" format="reference" /> <attr name="conversation_title_color" format="reference" />
<attr name="conversation_subtitle_color" format="reference" /> <attr name="conversation_subtitle_color" format="reference" />
<attr name="conversation_mention_background_color" format="reference" /> <attr name="conversation_mention_background_color" format="reference" />
<attr name="conversation_mention_divider_color" format="reference" />
<attr name="emoji_tab_strip_background" format="color" /> <attr name="emoji_tab_strip_background" format="color" />
<attr name="emoji_tab_indicator" format="color" /> <attr name="emoji_tab_indicator" format="color" />
@ -404,6 +405,7 @@
<attr name="scaleEmojis" format="boolean" /> <attr name="scaleEmojis" format="boolean" />
<attr name="emoji_maxLength" format="integer" /> <attr name="emoji_maxLength" format="integer" />
<attr name="emoji_forceCustom" format="boolean" /> <attr name="emoji_forceCustom" format="boolean" />
<attr name="emoji_renderMentions" format="boolean" />
</declare-styleable> </declare-styleable>
<declare-styleable name="RingtonePreference"> <declare-styleable name="RingtonePreference">

View File

@ -159,4 +159,6 @@
<dimen name="group_manage_fragment_row_horizontal_padding">16dp</dimen> <dimen name="group_manage_fragment_row_horizontal_padding">16dp</dimen>
<dimen name="wave_form_bar_width">2dp</dimen> <dimen name="wave_form_bar_width">2dp</dimen>
<dimen name="mentions_picker_peek_height">216dp</dimen>
</resources> </resources>

View File

@ -549,6 +549,7 @@
<string name="ManageGroupActivity_leave_group">Leave group</string> <string name="ManageGroupActivity_leave_group">Leave group</string>
<string name="ManageGroupActivity_mute_notifications">Mute notifications</string> <string name="ManageGroupActivity_mute_notifications">Mute notifications</string>
<string name="ManageGroupActivity_custom_notifications">Custom notifications</string> <string name="ManageGroupActivity_custom_notifications">Custom notifications</string>
<string name="ManageGroupActivity_mentions">Mentions</string>
<string name="ManageGroupActivity_until_s">Until %1$s</string> <string name="ManageGroupActivity_until_s">Until %1$s</string>
<string name="ManageGroupActivity_off">Off</string> <string name="ManageGroupActivity_off">Off</string>
<string name="ManageGroupActivity_on">On</string> <string name="ManageGroupActivity_on">On</string>
@ -576,6 +577,13 @@
<string name="ManageGroupActivity_legacy_group">Legacy Group</string> <string name="ManageGroupActivity_legacy_group">Legacy Group</string>
<string name="ManageGroupActivity_legacy_group_learn_more">This is a Legacy Group. To access features like group admins, create a New Group.</string> <string name="ManageGroupActivity_legacy_group_learn_more">This is a Legacy Group. To access features like group admins, create a New Group.</string>
<!-- GroupMentionSettingDialog -->
<string name="GroupMentionSettingDialog_notify_me_for_mentions">Notify me for Mentions</string>
<string name="GroupMentionSettingDialog_receive_notifications_when_youre_mentioned_in_muted_chats">Receive notifications when youre mentioned in muted chats?</string>
<string name="GroupMentionSettingDialog_default_notify_me">Default (Notify me)</string>
<string name="GroupMentionSettingDialog_default_dont_notify_me">Default (Don\'t notify me)</string>
<string name="GroupMentionSettingDialog_always_notify_me">Always notify me</string>
<string name="GroupMentionSettingDialog_dont_notify_me">Don\'t notify me</string>
<!-- ManageRecipientActivity --> <!-- ManageRecipientActivity -->
<string name="ManageRecipientActivity_add_to_system_contacts">Add to system contacts</string> <string name="ManageRecipientActivity_add_to_system_contacts">Add to system contacts</string>
@ -1995,6 +2003,9 @@
<string name="preferences_communication__sealed_sender_allow_from_anyone">Allow from anyone</string> <string name="preferences_communication__sealed_sender_allow_from_anyone">Allow from anyone</string>
<string name="preferences_communication__sealed_sender_allow_from_anyone_description">Enable sealed sender for incoming messages from non-contacts and people with whom you have not shared your profile.</string> <string name="preferences_communication__sealed_sender_allow_from_anyone_description">Enable sealed sender for incoming messages from non-contacts and people with whom you have not shared your profile.</string>
<string name="preferences_communication__sealed_sender_learn_more">Learn more</string> <string name="preferences_communication__sealed_sender_learn_more">Learn more</string>
<string name="preferences_notifications__mentions">Mentions</string>
<string name="preferences_notifications__notify_me">Notify me</string>
<string name="preferences_notifications__receive_notifications_when_youre_mentioned_in_muted_chats">Receive notifications when youre mentioned in muted chats</string>
<!-- Internal only preferences --> <!-- Internal only preferences -->
<string name="preferences__internal_preferences" translatable="false">Internal Preferences</string> <string name="preferences__internal_preferences" translatable="false">Internal Preferences</string>

View File

@ -263,6 +263,7 @@
<item name="conversation_title_color">@color/white</item> <item name="conversation_title_color">@color/white</item>
<item name="conversation_subtitle_color">@color/transparent_white_90</item> <item name="conversation_subtitle_color">@color/transparent_white_90</item>
<item name="conversation_mention_background_color">@color/core_grey_20</item> <item name="conversation_mention_background_color">@color/core_grey_20</item>
<item name="conversation_mention_divider_color">@color/core_grey_05</item>
<item name="safety_number_change_dialog_button_background">@color/core_grey_05</item> <item name="safety_number_change_dialog_button_background">@color/core_grey_05</item>
<item name="safety_number_change_dialog_button_text_color">@color/core_ultramarine</item> <item name="safety_number_change_dialog_button_text_color">@color/core_ultramarine</item>
@ -622,6 +623,7 @@
<item name="conversation_title_color">@color/transparent_white_90</item> <item name="conversation_title_color">@color/transparent_white_90</item>
<item name="conversation_subtitle_color">@color/transparent_white_80</item> <item name="conversation_subtitle_color">@color/transparent_white_80</item>
<item name="conversation_mention_background_color">@color/core_grey_75</item> <item name="conversation_mention_background_color">@color/core_grey_75</item>
<item name="conversation_mention_divider_color">@color/core_grey_25</item>
<item name="conversation_scroll_to_bottom_background">@drawable/scroll_to_bottom_background_dark</item> <item name="conversation_scroll_to_bottom_background">@drawable/scroll_to_bottom_background_dark</item>
<item name="conversation_scroll_to_bottom_foreground_color">@color/core_white</item> <item name="conversation_scroll_to_bottom_foreground_color">@color/core_white</item>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory android:layout="@layout/preference_divider"/>
<PreferenceCategory android:title="@string/preferences_notifications__mentions">
<org.thoughtcrime.securesms.components.SwitchPreferenceCompat
android:key="notifications.mentions.notify_me"
android:title="@string/preferences_notifications__notify_me"
android:summary="@string/preferences_notifications__receive_notifications_when_youre_mentioned_in_muted_chats"
android:defaultValue="true" />
</PreferenceCategory>
</PreferenceScreen>

View File

@ -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<Mention> mentions;
private final String updatedBody;
private final List<Mention> updatedMentions;
@Parameters
public static Collection<Object[]> 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<Mention> mentions, String updatedBody, List<Mention> 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<Mention> mentions = new ArrayList<>();
private List<Mention> 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 };
}
}
}

View File

@ -578,11 +578,22 @@ public class SignalServiceMessageSender {
.setText(message.getQuote().get().getText()); .setText(message.getQuote().get().getText());
if (message.getQuote().get().getAuthor().getUuid().isPresent()) { 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()) { 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()) { 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()) { if (message.getSticker().isPresent()) {
DataMessage.Sticker.Builder stickerBuilder = DataMessage.Sticker.newBuilder(); DataMessage.Sticker.Builder stickerBuilder = DataMessage.Sticker.newBuilder();

View File

@ -347,6 +347,7 @@ public final class SignalServiceContent {
SignalServiceDataMessage.Quote quote = createQuote(content); SignalServiceDataMessage.Quote quote = createQuote(content);
List<SharedContact> sharedContacts = createSharedContacts(content); List<SharedContact> sharedContacts = createSharedContacts(content);
List<SignalServiceDataMessage.Preview> previews = createPreviews(content); List<SignalServiceDataMessage.Preview> previews = createPreviews(content);
List<SignalServiceDataMessage.Mention> mentions = createMentions(content);
SignalServiceDataMessage.Sticker sticker = createSticker(content); SignalServiceDataMessage.Sticker sticker = createSticker(content);
SignalServiceDataMessage.Reaction reaction = createReaction(content); SignalServiceDataMessage.Reaction reaction = createReaction(content);
SignalServiceDataMessage.RemoteDelete remoteDelete = createRemoteDelete(content); SignalServiceDataMessage.RemoteDelete remoteDelete = createRemoteDelete(content);
@ -381,6 +382,7 @@ public final class SignalServiceContent {
quote, quote,
sharedContacts, sharedContacts,
previews, previews,
mentions,
sticker, sticker,
content.getIsViewOnce(), content.getIsViewOnce(),
reaction, reaction,
@ -662,7 +664,8 @@ public final class SignalServiceContent {
return new SignalServiceDataMessage.Quote(content.getQuote().getId(), return new SignalServiceDataMessage.Quote(content.getQuote().getId(),
address, address,
content.getQuote().getText(), content.getQuote().getText(),
attachments); attachments,
createMentions(content));
} else { } else {
Log.w(TAG, "Quote was missing an author! Returning null."); Log.w(TAG, "Quote was missing an author! Returning null.");
return null; return null;
@ -689,6 +692,35 @@ public final class SignalServiceContent {
return results; return results;
} }
private static List<SignalServiceDataMessage.Mention> createMentions(SignalServiceProtos.DataMessage content) throws ProtocolInvalidMessageException {
if (content.getBodyRangesCount() <= 0 || !content.hasBody()) return null;
List<SignalServiceDataMessage.Mention> 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 { private static SignalServiceDataMessage.Sticker createSticker(SignalServiceProtos.DataMessage content) throws ProtocolInvalidMessageException {
if (!content.hasSticker() || if (!content.hasSticker() ||
!content.getSticker().hasPackId() || !content.getSticker().hasPackId() ||

View File

@ -14,6 +14,7 @@ import org.whispersystems.signalservice.api.util.OptionalUtil;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.UUID;
/** /**
* Represents a decrypted Signal Service data message. * Represents a decrypted Signal Service data message.
@ -32,6 +33,7 @@ public class SignalServiceDataMessage {
private final Optional<Quote> quote; private final Optional<Quote> quote;
private final Optional<List<SharedContact>> contacts; private final Optional<List<SharedContact>> contacts;
private final Optional<List<Preview>> previews; private final Optional<List<Preview>> previews;
private final Optional<List<Mention>> mentions;
private final Optional<Sticker> sticker; private final Optional<Sticker> sticker;
private final boolean viewOnce; private final boolean viewOnce;
private final Optional<Reaction> reaction; private final Optional<Reaction> reaction;
@ -54,7 +56,7 @@ public class SignalServiceDataMessage {
String body, boolean endSession, int expiresInSeconds, String body, boolean endSession, int expiresInSeconds,
boolean expirationUpdate, byte[] profileKey, boolean profileKeyUpdate, boolean expirationUpdate, byte[] profileKey, boolean profileKeyUpdate,
Quote quote, List<SharedContact> sharedContacts, List<Preview> previews, Quote quote, List<SharedContact> sharedContacts, List<Preview> previews,
Sticker sticker, boolean viewOnce, Reaction reaction, RemoteDelete remoteDelete) List<Mention> mentions, Sticker sticker, boolean viewOnce, Reaction reaction, RemoteDelete remoteDelete)
{ {
try { try {
this.group = SignalServiceGroupContext.createOptional(group, groupV2); this.group = SignalServiceGroupContext.createOptional(group, groupV2);
@ -92,6 +94,12 @@ public class SignalServiceDataMessage {
} else { } else {
this.previews = Optional.absent(); this.previews = Optional.absent();
} }
if (mentions != null && !mentions.isEmpty()) {
this.mentions = Optional.of(mentions);
} else {
this.mentions = Optional.absent();
}
} }
public static Builder newBuilder() { public static Builder newBuilder() {
@ -174,6 +182,10 @@ public class SignalServiceDataMessage {
return previews; return previews;
} }
public Optional<List<Mention>> getMentions() {
return mentions;
}
public Optional<Sticker> getSticker() { public Optional<Sticker> getSticker() {
return sticker; return sticker;
} }
@ -195,6 +207,7 @@ public class SignalServiceDataMessage {
private List<SignalServiceAttachment> attachments = new LinkedList<>(); private List<SignalServiceAttachment> attachments = new LinkedList<>();
private List<SharedContact> sharedContacts = new LinkedList<>(); private List<SharedContact> sharedContacts = new LinkedList<>();
private List<Preview> previews = new LinkedList<>(); private List<Preview> previews = new LinkedList<>();
private List<Mention> mentions = new LinkedList<>();
private long timestamp; private long timestamp;
private SignalServiceGroup group; private SignalServiceGroup group;
@ -302,6 +315,11 @@ public class SignalServiceDataMessage {
return this; return this;
} }
public Builder withMentions(List<Mention> mentions) {
this.mentions.addAll(mentions);
return this;
}
public Builder withSticker(Sticker sticker) { public Builder withSticker(Sticker sticker) {
this.sticker = sticker; this.sticker = sticker;
return this; return this;
@ -327,7 +345,7 @@ public class SignalServiceDataMessage {
return new SignalServiceDataMessage(timestamp, group, groupV2, attachments, body, endSession, return new SignalServiceDataMessage(timestamp, group, groupV2, attachments, body, endSession,
expiresInSeconds, expirationUpdate, profileKey, expiresInSeconds, expirationUpdate, profileKey,
profileKeyUpdate, quote, sharedContacts, previews, 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 SignalServiceAddress author;
private final String text; private final String text;
private final List<QuotedAttachment> attachments; private final List<QuotedAttachment> attachments;
private final List<Mention> mentions;
public Quote(long id, SignalServiceAddress author, String text, List<QuotedAttachment> attachments) { public Quote(long id, SignalServiceAddress author, String text, List<QuotedAttachment> attachments, List<Mention> mentions) {
this.id = id; this.id = id;
this.author = author; this.author = author;
this.text = text; this.text = text;
this.attachments = attachments; this.attachments = attachments;
this.mentions = mentions;
} }
public long getId() { public long getId() {
@ -360,6 +380,10 @@ public class SignalServiceDataMessage {
return attachments; return attachments;
} }
public List<Mention> getMentions() {
return mentions;
}
public static class QuotedAttachment { public static class QuotedAttachment {
private final String contentType; private final String contentType;
private final String fileName; private final String fileName;
@ -480,4 +504,28 @@ public class SignalServiceDataMessage {
return targetSentTimestamp; 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;
}
}
} }

View File

@ -111,6 +111,15 @@ message DataMessage {
PROFILE_KEY_UPDATE = 4; PROFILE_KEY_UPDATE = 4;
} }
message BodyRange {
optional int32 start = 1;
optional int32 length = 2;
oneof associatedValue {
string mentionUuid = 3;
}
}
message Quote { message Quote {
message QuotedAttachment { message QuotedAttachment {
optional string contentType = 1; optional string contentType = 1;
@ -123,6 +132,7 @@ message DataMessage {
optional string authorUuid = 5; optional string authorUuid = 5;
optional string text = 3; optional string text = 3;
repeated QuotedAttachment attachments = 4; repeated QuotedAttachment attachments = 4;
repeated BodyRange bodyRanges = 6;
} }
message Contact { message Contact {
@ -226,7 +236,8 @@ message DataMessage {
VIEW_ONCE_VIDEO = 3; VIEW_ONCE_VIDEO = 3;
REACTIONS = 4; REACTIONS = 4;
CDN_SELECTOR_ATTACHMENTS = 5; CDN_SELECTOR_ATTACHMENTS = 5;
CURRENT = 5; MENTIONS = 6;
CURRENT = 6;
} }
optional string body = 1; optional string body = 1;
@ -245,6 +256,7 @@ message DataMessage {
optional bool isViewOnce = 14; optional bool isViewOnce = 14;
optional Reaction reaction = 16; optional Reaction reaction = 16;
optional Delete delete = 17; optional Delete delete = 17;
repeated BodyRange bodyRanges = 18;
} }
message NullMessage { message NullMessage {