Add mentions for v2 group chats.

master
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 onInviteSharedContactClicked(@NonNull List<Recipient> choices);
void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
}
}

View File

@ -5,6 +5,7 @@ import android.content.res.Configuration;
import android.graphics.Canvas;
import android.os.Build;
import android.os.Bundle;
import android.text.Annotation;
import android.text.Editable;
import android.text.InputType;
import android.text.Spannable;
@ -13,7 +14,6 @@ import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.method.QwertyKeyListener;
import android.text.style.RelativeSizeSpan;
import android.util.AttributeSet;
import android.view.inputmethod.EditorInfo;
@ -21,7 +21,6 @@ import android.view.inputmethod.InputConnection;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.BuildCompat;
import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
@ -30,18 +29,26 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionDeleter;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.UUID;
import java.util.List;
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
public class ComposeText extends EmojiEditText {
private CharSequence hint;
private SpannableString subHint;
private CharSequence combinedHint;
private MentionRendererDelegate mentionRendererDelegate;
private MentionValidatorWatcher mentionValidatorWatcher;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@ -62,47 +69,63 @@ public class ComposeText extends EmojiEditText {
initialize();
}
public String getTextTrimmed(){
return getText().toString().trim();
/**
* Trims and returns text while preserving potential spans like {@link MentionAnnotation}.
*/
public @NonNull CharSequence getTextTrimmed() {
Editable text = getText();
if (text == null) {
return "";
}
return StringUtil.trimSequence(text);
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
if (!TextUtils.isEmpty(hint)) {
if (!TextUtils.isEmpty(subHint)) {
setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
.append("\n")
.append(ellipsizeToWidth(subHint)));
} else {
setHint(ellipsizeToWidth(hint));
}
if (!TextUtils.isEmpty(combinedHint)) {
setHint(combinedHint);
}
}
@Override
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
protected void onSelectionChanged(int selectionStart, int selectionEnd) {
super.onSelectionChanged(selectionStart, selectionEnd);
if (FeatureFlags.mentions()) {
if (selStart == selEnd) {
doAfterCursorChange();
if (FeatureFlags.mentions() && getText() != null) {
boolean selectionChanged = changeSelectionForPartialMentions(getText(), selectionStart, selectionEnd);
if (selectionChanged) {
return;
}
if (selectionStart == selectionEnd) {
doAfterCursorChange(getText());
} else {
updateQuery("");
}
}
if (cursorPositionChangedListener != null) {
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
cursorPositionChangedListener.onCursorPositionChanged(selectionStart, selectionEnd);
}
}
@Override
protected void onDraw(Canvas canvas) {
if (FeatureFlags.mentions() && getText() != null && getLayout() != null) {
if (getText() != null && getLayout() != null) {
int checkpoint = canvas.save();
// Clip using same logic as TextView drawing
int maxScrollY = getLayout().getHeight() - getBottom() - getTop() - getCompoundPaddingBottom() - getCompoundPaddingTop();
float clipLeft = getCompoundPaddingLeft() + getScrollX();
float clipTop = (getScrollY() == 0) ? 0 : getExtendedPaddingTop() + getScrollY();
float clipRight = getRight() - getLeft() - getCompoundPaddingRight() + getScrollX();
float clipBottom = getBottom() - getTop() + getScrollY() - ((getScrollY() == maxScrollY) ? 0 : getExtendedPaddingBottom());
canvas.clipRect(clipLeft - 10, clipTop, clipRight + 10, clipBottom);
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
try {
mentionRendererDelegate.draw(canvas, getText(), getLayout());
} finally {
@ -120,25 +143,25 @@ public class ComposeText extends EmojiEditText {
}
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
this.hint = hint;
if (subHint != null) {
this.subHint = new SpannableString(subHint);
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
Spannable subHintSpannable = new SpannableString(subHint);
subHintSpannable.setSpan(new RelativeSizeSpan(0.5f), 0, subHintSpannable.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
combinedHint = new SpannableStringBuilder().append(ellipsizeToWidth(hint))
.append("\n")
.append(ellipsizeToWidth(subHintSpannable));
} else {
this.subHint = null;
combinedHint = ellipsizeToWidth(hint);
}
if (this.subHint != null) {
super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
.append("\n")
.append(ellipsizeToWidth(this.subHint)));
} else {
super.setHint(ellipsizeToWidth(this.hint));
}
super.setHint(combinedHint);
}
public void appendInvite(String invite) {
if (getText() == null) {
return;
}
if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) {
append(" ");
}
@ -155,13 +178,18 @@ public class ComposeText extends EmojiEditText {
this.mentionQueryChangedListener = listener;
}
public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) {
if (FeatureFlags.mentions()) {
mentionValidatorWatcher.setMentionValidator(mentionValidator);
}
}
private boolean isLandscape() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
}
public void setTransport(TransportOption transport) {
final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext());
final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext());
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
int inputType = getInputType();
@ -201,19 +229,59 @@ public class ComposeText extends EmojiEditText {
this.mediaListener = mediaListener;
}
public boolean hasMentions() {
Editable text = getText();
if (text != null) {
return !MentionAnnotation.getMentionAnnotations(text).isEmpty();
}
return false;
}
public @NonNull List<Mention> getMentions() {
return MentionAnnotation.getMentionsFromAnnotations(getText());
}
private void initialize() {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
}
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ThemeUtil.getThemedColor(getContext(), R.attr.conversation_mention_background_color));
if (FeatureFlags.mentions()) {
mentionRendererDelegate = new MentionRendererDelegate(getContext());
addTextChangedListener(new MentionDeleter());
mentionValidatorWatcher = new MentionValidatorWatcher();
addTextChangedListener(mentionValidatorWatcher);
}
}
private void doAfterCursorChange() {
Editable text = getText();
if (text != null && enoughToFilter(text)) {
private boolean changeSelectionForPartialMentions(@NonNull Spanned spanned, int selectionStart, int selectionEnd) {
Annotation[] annotations = spanned.getSpans(0, spanned.length(), Annotation.class);
for (Annotation annotation : annotations) {
if (MentionAnnotation.isMentionAnnotation(annotation)) {
int spanStart = spanned.getSpanStart(annotation);
int spanEnd = spanned.getSpanEnd(annotation);
boolean startInMention = selectionStart > spanStart && selectionStart < spanEnd;
boolean endInMention = selectionEnd > spanStart && selectionEnd < spanEnd;
if (startInMention || endInMention) {
if (selectionStart == selectionEnd) {
setSelection(spanEnd, spanEnd);
} else {
int newStart = startInMention ? spanStart : selectionStart;
int newEnd = endInMention ? spanEnd : selectionEnd;
setSelection(newStart, newEnd);
}
return true;
}
}
}
return false;
}
private void doAfterCursorChange(@NonNull Editable text) {
if (enoughToFilter(text)) {
performFiltering(text);
} else {
updateQuery("");
@ -241,7 +309,7 @@ public class ComposeText extends EmojiEditText {
return end - findQueryStart(text, end) >= 1;
}
public void replaceTextWithMention(@NonNull String displayName, @NonNull UUID uuid) {
public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) {
Editable text = getText();
if (text == null) {
return;
@ -251,14 +319,12 @@ public class ComposeText extends EmojiEditText {
int end = getSelectionEnd();
int start = findQueryStart(text, end) - 1;
String original = TextUtils.substring(text, start, end);
QwertyKeyListener.markAsReplaced(text, start, end, original);
text.replace(start, end, createReplacementToken(displayName, uuid));
text.replace(start, end, createReplacementToken(displayName, recipientId));
}
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull UUID uuid) {
SpannableStringBuilder builder = new SpannableStringBuilder("@");
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) {
SpannableStringBuilder builder = new SpannableStringBuilder().append(MENTION_STARTER);
if (text instanceof Spanned) {
SpannableString spannableString = new SpannableString(text + " ");
TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0);
@ -267,7 +333,7 @@ public class ComposeText extends EmojiEditText {
builder.append(text).append(" ");
}
builder.setSpan(MentionAnnotation.mentionAnnotationForUuid(uuid), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(recipientId), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return builder;
}
@ -278,11 +344,11 @@ public class ComposeText extends EmojiEditText {
}
int delimiterSearchIndex = inputCursorPosition - 1;
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != '@' && text.charAt(delimiterSearchIndex) != ' ')) {
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != MENTION_STARTER && text.charAt(delimiterSearchIndex) != ' ')) {
delimiterSearchIndex--;
}
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == '@') {
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == MENTION_STARTER) {
return delimiterSearchIndex + 1;
}
return inputCursorPosition;
@ -300,7 +366,7 @@ public class ComposeText extends EmojiEditText {
@Override
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
try {
inputContentInfo.requestPermission();
} catch (Exception e) {

View File

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

View File

@ -23,6 +23,8 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
@ -55,7 +57,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private long id;
private LiveRecipient author;
private String body;
private CharSequence body;
private TextView mediaDescriptionText;
private TextView missingLinkText;
private SlideDeck attachments;
@ -147,7 +149,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
public void setQuote(GlideRequests glideRequests,
long id,
@NonNull Recipient author,
@Nullable String body,
@Nullable CharSequence body,
boolean originalMissing,
@NonNull SlideDeck attachments)
{
@ -196,7 +198,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing));
}
private void setQuoteText(@Nullable String body, @NonNull SlideDeck attachments) {
private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) {
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
bodyView.setVisibility(VISIBLE);
bodyView.setText(body == null ? "" : body);
@ -280,11 +282,15 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
return author.get();
}
public String getBody() {
public CharSequence getBody() {
return body;
}
public List<Attachment> getAttachments() {
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.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.core.widget.TextViewCompat;
import androidx.appcompat.widget.AppCompatTextView;
import android.text.Annotation;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.TypedValue;
@ -15,10 +20,15 @@ import android.util.TypedValue;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
public class EmojiTextView extends AppCompatTextView {
@ -35,6 +45,9 @@ public class EmojiTextView extends AppCompatTextView {
private int maxLength;
private CharSequence overflowText;
private CharSequence previousOverflowText;
private boolean renderMentions;
private MentionRendererDelegate mentionRendererDelegate;
public EmojiTextView(Context context) {
this(context, null);
@ -48,14 +61,33 @@ public class EmojiTextView extends AppCompatTextView {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
a.recycle();
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
originalFontSize = a.getDimensionPixelSize(0, 0);
a.recycle();
if (renderMentions) {
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
}
}
@Override
protected void onDraw(Canvas canvas) {
if (renderMentions && getText() instanceof Spanned && getLayout() != null) {
int checkpoint = canvas.save();
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
try {
mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout());
} finally {
canvas.restoreToCount(checkpoint);
}
}
super.onDraw(canvas);
}
@Override public void setText(@Nullable CharSequence text, BufferType type) {
@ -115,7 +147,19 @@ public class EmojiTextView extends AppCompatTextView {
private void ellipsizeAnyTextForMaxLength() {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();
newContent.append(getText().subSequence(0, maxLength)).append(ELLIPSIS).append(Optional.fromNullable(overflowText).or(""));
CharSequence shortenedText = getText().subSequence(0, maxLength);
if (shortenedText instanceof Spanned) {
Spanned spanned = (Spanned) shortenedText;
List<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);

View File

@ -2,16 +2,26 @@ package org.thoughtcrime.securesms.components.mention;
import android.text.Annotation;
import android.text.Spannable;
import android.text.Spanned;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.UUID;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Collections;
import java.util.List;
/**
* Factory for creating mention annotation spans.
* This wraps an Android standard {@link Annotation} so it can leverage the built in
* span parceling for copy/paste. The annotation span contains the mentioned recipient's
* id (in numerical form).
*
* Note: This wraps creating an Android standard {@link Annotation} so it can leverage the built in
* span parceling for copy/paste. Do not extend Annotation or this will be lost.
* Note: Do not extend Annotation or the parceling behavior will be lost.
*/
public final class MentionAnnotation {
@ -20,7 +30,45 @@ public final class MentionAnnotation {
private MentionAnnotation() {
}
public static Annotation mentionAnnotationForUuid(@NonNull UUID uuid) {
return new Annotation(MENTION_ANNOTATION, uuid.toString());
public static Annotation mentionAnnotationForRecipientId(@NonNull RecipientId id) {
return new Annotation(MENTION_ANNOTATION, idToMentionAnnotationValue(id));
}
public static String idToMentionAnnotationValue(@NonNull RecipientId id) {
return String.valueOf(id.toLong());
}
public static boolean isMentionAnnotation(@NonNull Annotation annotation) {
return MENTION_ANNOTATION.equals(annotation.getKey());
}
public static void setMentionAnnotations(Spannable body, List<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.NonNull;
import androidx.annotation.Px;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
/**
@ -28,42 +28,32 @@ public class MentionRendererDelegate {
private final MentionRenderer multi;
private final int horizontalPadding;
public MentionRendererDelegate(@NonNull Context context) {
//noinspection ConstantConditions
this(ViewUtil.dpToPx(2),
ViewUtil.dpToPx(2),
ContextCompat.getDrawable(context, R.drawable.mention_text_bg),
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left),
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid),
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right),
ThemeUtil.getThemedColor(context, R.attr.conversation_mention_background_color));
}
public MentionRendererDelegate(@NonNull Context context, @ColorInt int tint) {
this.horizontalPadding = ViewUtil.dpToPx(2);
public MentionRendererDelegate(int horizontalPadding,
int verticalPadding,
@NonNull Drawable drawable,
@NonNull Drawable drawableLeft,
@NonNull Drawable drawableMid,
@NonNull Drawable drawableEnd,
@ColorInt int tint)
{
this.horizontalPadding = horizontalPadding;
single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding,
verticalPadding,
DrawableUtil.tint(drawable, tint));
multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding,
verticalPadding,
DrawableUtil.tint(drawableLeft, tint),
DrawableUtil.tint(drawableMid, tint),
DrawableUtil.tint(drawableEnd, tint));
Drawable drawable = ContextCompat.getDrawable(context, R.drawable.mention_text_bg);
Drawable drawableLeft = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left);
Drawable drawableMid = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid);
Drawable drawableEnd = ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right);
//noinspection ConstantConditions
single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding,
0,
DrawableUtil.tint(drawable, tint));
//noinspection ConstantConditions
multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding,
0,
DrawableUtil.tint(drawableLeft, tint),
DrawableUtil.tint(drawableMid, tint),
DrawableUtil.tint(drawableEnd, tint));
}
public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) {
Annotation[] spans = text.getSpans(0, text.length(), Annotation.class);
for (Annotation span : spans) {
if (MentionAnnotation.MENTION_ANNOTATION.equals(span.getKey())) {
int spanStart = text.getSpanStart(span);
int spanEnd = text.getSpanEnd(span);
Annotation[] annotations = text.getSpans(0, text.length(), Annotation.class);
for (Annotation annotation : annotations) {
if (MentionAnnotation.isMentionAnnotation(annotation)) {
int spanStart = text.getSpanStart(annotation);
int spanEnd = text.getSpanEnd(annotation);
int startLine = layout.getLineForOffset(spanStart);
int endLine = layout.getLineForOffset(spanEnd);

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

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.net.Uri;
import android.text.Annotation;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
@ -71,6 +72,7 @@ import org.thoughtcrime.securesms.components.QuoteView;
import org.thoughtcrime.securesms.components.SharedContactView;
import org.thoughtcrime.securesms.components.BorderlessImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@ -552,7 +554,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
} else if (isCaptionlessMms(messageRecord)) {
bodyText.setVisibility(View.GONE);
} else {
Spannable styledText = linkifyMessageBody(messageRecord.getDisplayBody(getContext()), batchSelected.isEmpty());
Spannable styledText = linkifyMessageBody(conversationMessage.getDisplayBody(getContext()), batchSelected.isEmpty());
styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery);
styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery);
@ -855,7 +857,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
contactPhoto.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onGroupMemberAvatarClicked(recipientId, conversationRecipient.get().requireGroupId());
eventListener.onGroupMemberClicked(recipientId, conversationRecipient.get().requireGroupId());
}
});
@ -879,6 +881,12 @@ public class ConversationItem extends LinearLayout implements BindableConversati
messageBody.setSpan(new LongClickCopySpan(urlSpan.getURL()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
List<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;
}
@ -901,7 +909,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
Quote quote = ((MediaMmsMessageRecord)current).getQuote();
//noinspection ConstantConditions
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getText(), quote.isOriginalMissing(), quote.getAttachment());
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment());
quoteView.setVisibility(View.VISIBLE);
quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
@ -1405,6 +1413,24 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
}
private class MentionClickableSpan extends ClickableSpan {
private final RecipientId mentionedRecipientId;
MentionClickableSpan(RecipientId mentionedRecipientId) {
this.mentionedRecipientId = mentionedRecipientId;
}
@Override
public void onClick(@NonNull View widget) {
if (eventListener != null && !Recipient.resolved(mentionedRecipientId).isLocalNumber()) {
eventListener.onGroupMemberClicked(mentionedRecipientId, conversationRecipient.get().requireGroupId());
}
}
@Override
public void updateDrawState(@NonNull TextPaint ds) { }
}
private void handleMessageApproval() {
final int title;
final int message;

View File

@ -1,27 +1,58 @@
package org.thoughtcrime.securesms.conversation;
import androidx.annotation.NonNull;
import android.content.Context;
import android.text.SpannableString;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.Conversions;
import java.security.MessageDigest;
import java.util.Collections;
import java.util.List;
/**
* A view level model used to pass arbitrary message related information needed
* for various presentations.
*/
public class ConversationMessage {
private final MessageRecord messageRecord;
@NonNull private final MessageRecord messageRecord;
@NonNull private final List<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.body = body != null ? SpannableString.valueOf(body) : null;
this.mentions = mentions != null ? mentions : Collections.emptyList();
if (!this.mentions.isEmpty() && this.body != null) {
MentionAnnotation.setMentionAnnotations(this.body, this.mentions);
}
}
public @NonNull MessageRecord getMessageRecord() {
return messageRecord;
}
public @NonNull List<Mention> getMentions() {
return mentions;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@ -41,4 +72,74 @@ public class ConversationMessage {
return Conversions.byteArrayToLong(bytes);
}
public @NonNull SpannableString getDisplayBody(Context context) {
if (mentions.isEmpty() || body == null) {
return messageRecord.getDisplayBody(context);
}
return body;
}
/**
* Factory providing multiple ways of creating {@link ConversationMessage}s.
*/
public static class ConversationMessageFactory {
/**
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord. No database or
* heavy work performed as the message is assumed to not have any mentions.
*/
@AnyThread
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord) {
return new ConversationMessage(messageRecord);
}
/**
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord, potentially annotated body, and
* list of actual mentions. No database or heavy work performed as the body and mentions are assumed to be
* fully updated with display names.
*
* @param body Contains appropriate {@link MentionAnnotation}s and is updated with actual profile names.
* @param mentions List of actual mentions (i.e., not placeholder) matching annotation ranges in body.
*/
@AnyThread
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List<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 TextView name;
private final TextView username;
@Nullable private final MentionEventsListener mentionEventsListener;
public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) {
super(itemView);
this.mentionEventsListener = mentionEventsListener;
avatar = findViewById(R.id.mention_recipient_avatar);
name = findViewById(R.id.mention_recipient_name);
avatar = findViewById(R.id.mention_recipient_avatar);
name = findViewById(R.id.mention_recipient_name);
username = findViewById(R.id.mention_recipient_username);
}
@Override
public void bind(@NonNull MentionViewState model) {
avatar.setRecipient(model.getRecipient());
name.setText(model.getName(context));
username.setText(model.getUsername());
itemView.setOnClickListener(v -> {
if (mentionEventsListener != null) {
mentionEventsListener.onMentionClicked(model.getRecipient());

View File

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

View File

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

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;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
@ -11,6 +9,7 @@ import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
@ -20,7 +19,6 @@ import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Collections;
import java.util.List;
public class MentionsPickerViewModel extends ViewModel {
@ -28,17 +26,18 @@ public class MentionsPickerViewModel extends ViewModel {
private final SingleLiveEvent<Recipient> selectedRecipient;
private final LiveData<List<MappingModel<?>>> mentionList;
private final MutableLiveData<LiveGroup> group;
private final MutableLiveData<CharSequence> liveQuery;
private final MutableLiveData<String> liveQuery;
MentionsPickerViewModel() {
MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository) {
group = new MutableLiveData<>();
liveQuery = new MutableLiveData<>();
selectedRecipient = new SingleLiveEvent<>();
// TODO [cody] [mentions] simple query support implement for building UI/UX, to be replaced with better search before launch
LiveData<List<FullMember>> members = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers));
LiveData<List<FullMember>> fullMembers = 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() {
@ -54,7 +53,7 @@ public class MentionsPickerViewModel extends ViewModel {
}
public void onQueryChange(@NonNull CharSequence query) {
liveQuery.setValue(query);
liveQuery.setValue(query.toString());
}
public void onRecipientChange(@NonNull Recipient recipient) {
@ -65,22 +64,11 @@ public class MentionsPickerViewModel extends ViewModel {
}
}
private @NonNull List<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 {
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//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 MegaphoneDatabase megaphoneDatabase;
private final RemappedRecordsDatabase remappedRecordsDatabase;
private final MentionDatabase mentionDatabase;
public static DatabaseFactory getInstance(Context context) {
synchronized (lock) {
@ -165,6 +166,10 @@ public class DatabaseFactory {
return getInstance(context).remappedRecordsDatabase;
}
public static MentionDatabase getMentionDatabase(Context context) {
return getInstance(context).mentionDatabase;
}
public static SQLiteDatabase getBackupDatabase(Context context) {
return getInstance(context).databaseHelper.getReadableDatabase();
}
@ -214,6 +219,7 @@ public class DatabaseFactory {
this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper);
this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper);
this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper);
this.mentionDatabase = new MentionDatabase(context, databaseHelper);
}
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,

View File

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

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

View File

@ -82,6 +82,7 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.QUOTE_MENTIONS,
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE,
@ -89,7 +90,8 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.REMOTE_DELETED};
MmsSmsColumns.REMOTE_DELETED,
MmsDatabase.MENTIONS_SELF};
public MmsSmsDatabase(Context context, SQLCipherOpenHelper databaseHelper) {
super(context, databaseHelper);
@ -408,6 +410,7 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.QUOTE_MENTIONS,
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE,
@ -415,7 +418,8 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.DATE_SERVER,
MmsSmsColumns.REMOTE_DELETED };
MmsSmsColumns.REMOTE_DELETED,
MmsDatabase.MENTIONS_SELF };
String[] smsProjection = {SmsDatabase.DATE_SENT + " AS " + MmsSmsColumns.NORMALIZED_DATE_SENT,
SmsDatabase.DATE_RECEIVED + " AS " + MmsSmsColumns.NORMALIZED_DATE_RECEIVED,
@ -440,6 +444,7 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.QUOTE_BODY,
MmsDatabase.QUOTE_MISSING,
MmsDatabase.QUOTE_ATTACHMENT,
MmsDatabase.QUOTE_MENTIONS,
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE,
@ -447,7 +452,8 @@ public class MmsSmsDatabase extends Database {
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.DATE_SERVER,
MmsSmsColumns.REMOTE_DELETED };
MmsSmsColumns.REMOTE_DELETED,
MmsDatabase.MENTIONS_SELF };
SQLiteQueryBuilder mmsQueryBuilder = new SQLiteQueryBuilder();
SQLiteQueryBuilder smsQueryBuilder = new SQLiteQueryBuilder();
@ -493,6 +499,7 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.QUOTE_BODY);
mmsColumnsPresent.add(MmsDatabase.QUOTE_MISSING);
mmsColumnsPresent.add(MmsDatabase.QUOTE_ATTACHMENT);
mmsColumnsPresent.add(MmsDatabase.QUOTE_MENTIONS);
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE);
@ -500,6 +507,7 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD);
mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN);
mmsColumnsPresent.add(MmsDatabase.REMOTE_DELETED);
mmsColumnsPresent.add(MmsDatabase.MENTIONS_SELF);
Set<String> smsColumnsPresent = new HashSet<>();
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_FAMILY_NAME = "profile_family_name";
private static final String PROFILE_JOINED_NAME = "profile_joined_name";
private static final String MENTION_SETTING = "mention_setting";
public static final String SEARCH_PROFILE_NAME = "search_signal_profile";
private static final String SORT_NAME = "sort_name";
private static final String IDENTITY_STATUS = "identity_status";
private static final String IDENTITY_KEY = "identity_key";
private static final String[] RECIPIENT_PROJECTION = new String[] {
UUID, USERNAME, PHONE, EMAIL, GROUP_ID, GROUP_TYPE,
BLOCKED, MESSAGE_RINGTONE, CALL_RINGTONE, MESSAGE_VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, MESSAGE_EXPIRATION_TIME, REGISTERED,
@ -136,7 +136,8 @@ public class RecipientDatabase extends Database {
UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION,
UUID_CAPABILITY, GROUPS_V2_CAPABILITY,
STORAGE_SERVICE_ID, DIRTY
STORAGE_SERVICE_ID, DIRTY,
MENTION_SETTING
};
private static final String[] ID_PROJECTION = new String[]{ID};
@ -146,6 +147,8 @@ public class RecipientDatabase extends Database {
.map(columnName -> TABLE_NAME + "." + columnName)
.toList().toArray(new String[0]);
private static final String[] MENTION_SEARCH_PROJECTION = new String[]{ID, removeWhitespace("COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ", " + nullIfEmpty(PHONE) + ")") + " AS " + SORT_NAME};
private static final String[] RECIPIENT_FULL_PROJECTION = ArrayUtils.concat(
new String[] { TABLE_NAME + "." + ID },
TYPED_RECIPIENT_PROJECTION,
@ -275,6 +278,24 @@ public class RecipientDatabase extends Database {
}
}
public enum MentionSetting {
GLOBAL(0), ALWAYS_NOTIFY(1), DO_NOT_NOTIFY(2);
private final int id;
MentionSetting(int id) {
this.id = id;
}
int getId() {
return id;
}
public static MentionSetting fromId(int id) {
return values()[id];
}
}
public static final String CREATE_TABLE =
"CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
UUID + " TEXT UNIQUE DEFAULT NULL, " +
@ -314,7 +335,8 @@ public class RecipientDatabase extends Database {
UUID_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " +
GROUPS_V2_CAPABILITY + " INTEGER DEFAULT " + Recipient.Capability.UNKNOWN.serialize() + ", " +
STORAGE_SERVICE_ID + " TEXT UNIQUE DEFAULT NULL, " +
DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ");";
DIRTY + " INTEGER DEFAULT " + DirtyState.CLEAN.getId() + ", " +
MENTION_SETTING + " INTEGER DEFAULT " + MentionSetting.GLOBAL.getId() + ");";
private static final String INSIGHTS_INVITEE_LIST = "SELECT " + TABLE_NAME + "." + ID +
" FROM " + TABLE_NAME +
@ -1078,6 +1100,7 @@ public class RecipientDatabase extends Database {
int uuidCapabilityValue = CursorUtil.requireInt(cursor, UUID_CAPABILITY);
int groupsV2CapabilityValue = CursorUtil.requireInt(cursor, GROUPS_V2_CAPABILITY);
String storageKeyRaw = CursorUtil.requireString(cursor, STORAGE_SERVICE_ID);
int mentionSettingId = CursorUtil.requireInt(cursor, MENTION_SETTING);
Optional<String> identityKeyRaw = CursorUtil.getString(cursor, IDENTITY_KEY);
Optional<Integer> identityStatusRaw = CursorUtil.getInt(cursor, IDENTITY_STATUS);
@ -1145,7 +1168,7 @@ public class RecipientDatabase extends Database {
Recipient.Capability.deserialize(uuidCapabilityValue),
Recipient.Capability.deserialize(groupsV2CapabilityValue),
InsightsBannerTier.fromId(insightsBannerTier),
storageKey, identityKey, identityStatus);
storageKey, identityKey, identityStatus, MentionSetting.fromId(mentionSettingId));
}
public BulkOperationsHandle beginBulkSystemContactUpdate() {
@ -1299,6 +1322,14 @@ public class RecipientDatabase extends Database {
}
}
public void setMentionSetting(@NonNull RecipientId id, @NonNull MentionSetting mentionSetting) {
ContentValues values = new ContentValues();
values.put(MENTION_SETTING, mentionSetting.getId());
if (update(id, values)) {
Recipient.live(id).refresh();
}
}
/**
* Updates the profile key.
* <p>
@ -1902,6 +1933,29 @@ public class RecipientDatabase extends Database {
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null);
}
public @NonNull List<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
* characters.
@ -2384,6 +2438,10 @@ public class RecipientDatabase extends Database {
return "NULLIF(" + column + ", '')";
}
private static @NonNull String removeWhitespace(@NonNull String column) {
return "REPLACE(" + column + ", ' ', '')";
}
public interface ColorUpdater {
MaterialColor update(@NonNull String name, @Nullable String color);
}
@ -2427,6 +2485,7 @@ public class RecipientDatabase extends Database {
private final byte[] storageId;
private final byte[] identityKey;
private final IdentityDatabase.VerifiedStatus identityStatus;
private final MentionSetting mentionSetting;
RecipientSettings(@NonNull RecipientId id,
@Nullable UUID uuid,
@ -2465,7 +2524,8 @@ public class RecipientDatabase extends Database {
@NonNull InsightsBannerTier insightsBannerTier,
@Nullable byte[] storageId,
@Nullable byte[] identityKey,
@NonNull IdentityDatabase.VerifiedStatus identityStatus)
@NonNull IdentityDatabase.VerifiedStatus identityStatus,
@NonNull MentionSetting mentionSetting)
{
this.id = id;
this.uuid = uuid;
@ -2505,6 +2565,7 @@ public class RecipientDatabase extends Database {
this.storageId = storageId;
this.identityKey = identityKey;
this.identityStatus = identityStatus;
this.mentionSetting = mentionSetting;
}
public RecipientId getId() {
@ -2661,6 +2722,10 @@ public class RecipientDatabase extends Database {
public @NonNull IdentityDatabase.VerifiedStatus getIdentityStatus() {
return identityStatus;
}
public @NonNull MentionSetting getMentionSetting() {
return mentionSetting;
}
}
public static class RecipientReader implements Closeable {

View File

@ -66,7 +66,7 @@ public final class ThreadBodyUtil {
return context.getString(R.string.ThreadRecord_media_message);
} else {
Log.w(TAG, "Got a media message with a body of a type we were not able to process. [contains media slide]:" + record.containsMediaSlide());
return record.getBody();
return getBody(context, record);
}
}
@ -75,10 +75,10 @@ public final class ThreadBodyUtil {
}
private static @NonNull String getBodyOrDefault(@NonNull Context context, @NonNull MessageRecord record, @StringRes int defaultStringRes) {
if (TextUtils.isEmpty(record.getBody())) {
return context.getString(defaultStringRes);
} else {
return record.getBody();
}
return TextUtils.isEmpty(record.getBody()) ? context.getString(defaultStringRes) : getBody(context, record);
}
private static @NonNull String getBody(@NonNull Context context, @NonNull MessageRecord record) {
return MentionUtil.updateBodyWithDisplayNames(context, record, record.getBody()).toString();
}
}

View File

@ -22,6 +22,7 @@ import net.sqlcipher.database.SQLiteDatabaseHook;
import net.sqlcipher.database.SQLiteOpenHelper;
import org.thoughtcrime.securesms.contacts.avatars.ContactColorsLegacy;
import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.RemappedRecordsDatabase;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.ProfileName;
@ -139,8 +140,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int QUOTE_CLEANUP = 65;
private static final int BORDERLESS = 66;
private static final int REMAPPED_RECORDS = 67;
private static final int MENTIONS = 68;
private static final int DATABASE_VERSION = 67;
private static final int DATABASE_VERSION = 68;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@ -184,6 +186,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL(StorageKeyDatabase.CREATE_TABLE);
db.execSQL(KeyValueDatabase.CREATE_TABLE);
db.execSQL(MegaphoneDatabase.CREATE_TABLE);
db.execSQL(MentionDatabase.CREATE_TABLE);
executeStatements(db, SearchDatabase.CREATE_TABLE);
executeStatements(db, JobDatabase.CREATE_TABLE);
executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE);
@ -198,6 +201,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
executeStatements(db, GroupReceiptDatabase.CREATE_INDEXES);
executeStatements(db, StickerDatabase.CREATE_INDEXES);
executeStatements(db, StorageKeyDatabase.CREATE_INDEXES);
executeStatements(db, MentionDatabase.CREATE_INDEXES);
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context);
@ -970,6 +974,23 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
"new_id INTEGER)");
}
if (oldVersion < MENTIONS) {
db.execSQL("CREATE TABLE mention (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"thread_id INTEGER, " +
"message_id INTEGER, " +
"recipient_id INTEGER, " +
"range_start INTEGER, " +
"range_length INTEGER)");
db.execSQL("CREATE INDEX IF NOT EXISTS mention_message_id_index ON mention (message_id)");
db.execSQL("CREATE INDEX IF NOT EXISTS mention_recipient_id_thread_id_index ON mention (recipient_id, thread_id);");
db.execSQL("ALTER TABLE mms ADD COLUMN quote_mentions BLOB DEFAULT NULL");
db.execSQL("ALTER TABLE mms ADD COLUMN mentions_self INTEGER DEFAULT 0");
db.execSQL("ALTER TABLE recipient ADD COLUMN mention_setting INTEGER DEFAULT 0");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();

View File

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

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() {
return reactions;
}
public boolean hasSelfMention() {
return false;
}
}

View File

@ -1,26 +1,42 @@
package org.thoughtcrime.securesms.database.model;
import android.text.SpannableString;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.List;
public class Quote {
private final long id;
private final RecipientId author;
private final String text;
private final boolean missing;
private final SlideDeck attachment;
private final long id;
private final RecipientId author;
private final CharSequence text;
private final boolean missing;
private final SlideDeck attachment;
private final List<Mention> mentions;
public Quote(long id, @NonNull RecipientId author, @Nullable String text, boolean missing, @NonNull SlideDeck attachment) {
this.id = id;
this.author = author;
this.text = text;
this.missing = missing;
this.attachment = attachment;
public Quote(long id,
@NonNull RecipientId author,
@Nullable CharSequence text,
boolean missing,
@NonNull SlideDeck attachment,
@NonNull List<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() {
@ -31,7 +47,7 @@ public class Quote {
return author;
}
public @Nullable String getText() {
public @Nullable CharSequence getDisplayText() {
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);
}
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList());
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
long threadId = MessageSender.send(context, outgoingMessage, -1, false, null);
return new GroupActionResult(groupRecipient, threadId, newMemberCount, Collections.emptyList());
@ -241,6 +241,7 @@ final class GroupManagerV1 {
false,
null,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList()));
}
}

View File

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

View File

@ -240,7 +240,7 @@ public final class GroupV1MessageProcessor {
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
RecipientId recipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(GroupId.v1orThrow(group.getGroupId()));
Recipient recipient = Recipient.resolved(recipientId);
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(recipient, storage, null, content.getTimestamp(), 0, false, null, Collections.emptyList(), Collections.emptyList());
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(recipient, storage, null, content.getTimestamp(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(recipient);
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);

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.notifications.CustomNotificationsDialogFragment;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.LifecycleCursorWrapper;
import org.thoughtcrime.securesms.util.views.LearnMoreTextView;
@ -99,6 +100,8 @@ public class ManageGroupFragment extends LoggingFragment {
private TextView muteNotificationsUntilLabel;
private TextView customNotificationsButton;
private View customNotificationsRow;
private View mentionsRow;
private TextView mentionsValue;
private View toggleAllMembers;
private final Recipient.FallbackPhotoProvider fallbackPhotoProvider = new Recipient.FallbackPhotoProvider() {
@ -156,6 +159,8 @@ public class ManageGroupFragment extends LoggingFragment {
muteNotificationsRow = view.findViewById(R.id.group_mute_notifications_row);
customNotificationsButton = view.findViewById(R.id.group_custom_notifications_button);
customNotificationsRow = view.findViewById(R.id.group_custom_notifications_row);
mentionsRow = view.findViewById(R.id.group_mentions_row);
mentionsValue = view.findViewById(R.id.group_mentions_value);
toggleAllMembers = view.findViewById(R.id.toggle_all_members);
groupV1Indicator.setOnLinkClickListener(v -> GroupsLearnMoreBottomSheetDialogFragment.show(requireFragmentManager()));
@ -317,7 +322,6 @@ public class ManageGroupFragment extends LoggingFragment {
customNotificationsRow.setVisibility(View.VISIBLE);
//noinspection CodeBlock2Expr
if (NotificationChannels.supported()) {
viewModel.hasCustomNotifications().observe(getViewLifecycleOwner(), hasCustomNotifications -> {
customNotificationsButton.setText(hasCustomNotifications ? R.string.ManageGroupActivity_on
@ -325,6 +329,10 @@ public class ManageGroupFragment extends LoggingFragment {
});
}
mentionsRow.setVisibility(FeatureFlags.mentions() && groupId.isV2() ? View.VISIBLE : View.GONE);
mentionsRow.setOnClickListener(v -> viewModel.handleMentionNotificationSelection());
viewModel.getMentionSetting().observe(getViewLifecycleOwner(), value -> mentionsValue.setText(value));
viewModel.getSnackbarEvents().observe(getViewLifecycleOwner(), this::handleSnackbarEvent);
viewModel.getInvitedDialogEvents().observe(getViewLifecycleOwner(), this::handleInvitedDialogEvent);

View File

@ -12,6 +12,7 @@ import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.groups.GroupAccessControl;
import org.thoughtcrime.securesms.groups.GroupChangeException;
@ -152,6 +153,13 @@ final class ManageGroupRepository {
});
}
void setMentionSetting(RecipientDatabase.MentionSetting mentionSetting) {
SignalExecutors.BOUNDED.execute(() -> {
RecipientId recipientId = Recipient.externalGroup(context, groupId).getId();
DatabaseFactory.getRecipientDatabase(context).setMentionSetting(recipientId, mentionSetting);
});
}
static final class GroupStateResult {
private final long threadId;

View File

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

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

View File

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

View File

@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.StickerDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
@ -123,6 +124,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.ViewOnceOpenMes
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException;
import java.security.SecureRandom;
@ -133,6 +135,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public final class PushProcessMessageJob extends BaseJob {
@ -341,7 +344,7 @@ public final class PushProcessMessageJob extends BaseJob {
if (content.getDataMessage().isPresent()) {
SignalServiceDataMessage message = content.getDataMessage().get();
boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent();
boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent() || message.getPreviews().isPresent() || message.getSticker().isPresent() || message.getMentions().isPresent();
Optional<GroupId> groupId = GroupUtil.idFromGroupContext(message.getGroupContext());
boolean isGv2Message = groupId.isPresent() && groupId.get().isV2();
@ -729,6 +732,7 @@ public final class PushProcessMessageJob extends BaseJob {
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent(),
Optional.absent());
database.insertSecureDecryptedMessageInbox(mediaMessage, -1);
@ -1029,6 +1033,7 @@ public final class PushProcessMessageJob extends BaseJob {
Optional<QuoteModel> quote = getValidatedQuote(message.getQuote());
Optional<List<Contact>> sharedContacts = getContacts(message.getSharedContacts());
Optional<List<LinkPreview>> linkPreviews = getLinkPreviews(message.getPreviews(), message.getBody().or(""));
Optional<List<Mention>> mentions = getMentions(message.getMentions());
Optional<Attachment> sticker = getStickerAttachment(message.getSticker());
IncomingMediaMessage mediaMessage = new IncomingMediaMessage(RecipientId.fromHighTrust(content.getSender()),
message.getTimestamp(),
@ -1044,6 +1049,7 @@ public final class PushProcessMessageJob extends BaseJob {
quote,
sharedContacts,
linkPreviews,
mentions,
sticker);
insertResult = database.insertSecureDecryptedMessageInbox(mediaMessage, -1);
@ -1109,6 +1115,7 @@ public final class PushProcessMessageJob extends BaseJob {
Optional<Attachment> sticker = getStickerAttachment(message.getMessage().getSticker());
Optional<List<Contact>> sharedContacts = getContacts(message.getMessage().getSharedContacts());
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();
List<Attachment> syncAttachments = viewOnce ? Collections.singletonList(new TombstoneAttachment(MediaUtil.VIEW_ONCE, false))
: PointerAttachment.forPointers(message.getMessage().getAttachments());
@ -1125,6 +1132,7 @@ public final class PushProcessMessageJob extends BaseJob {
ThreadDatabase.DistributionTypes.DEFAULT, quote.orNull(),
sharedContacts.or(Collections.emptyList()),
previews.or(Collections.emptyList()),
mentions.or(Collections.emptyList()),
Collections.emptyList(), Collections.emptyList());
mediaMessage = new OutgoingSecureMediaMessage(mediaMessage);
@ -1290,7 +1298,18 @@ public final class PushProcessMessageJob extends BaseJob {
long messageId;
if (isGroup) {
OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient, new SlideDeck(), body, message.getTimestamp(), -1, expiresInMillis, false, ThreadDatabase.DistributionTypes.DEFAULT, null, Collections.emptyList(), Collections.emptyList());
OutgoingMediaMessage outgoingMediaMessage = new OutgoingMediaMessage(recipient,
new SlideDeck(),
body,
message.getTimestamp(),
-1,
expiresInMillis,
false,
ThreadDatabase.DistributionTypes.DEFAULT,
null,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList());
outgoingMediaMessage = new OutgoingSecureMediaMessage(outgoingMediaMessage);
messageId = DatabaseFactory.getMmsDatabase(context).insertMessageOutbox(outgoingMediaMessage, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null);
@ -1577,10 +1596,13 @@ public final class PushProcessMessageJob extends BaseJob {
Log.i(TAG, "Found matching message record...");
List<Attachment> attachments = new LinkedList<>();
List<Mention> mentions = new LinkedList<>();
if (message.isMms()) {
MmsMessageRecord mmsMessage = (MmsMessageRecord) message;
mentions.addAll(DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(mmsMessage.getId()));
if (mmsMessage.isViewOnce()) {
attachments.add(new TombstoneAttachment(MediaUtil.VIEW_ONCE, true));
} else {
@ -1595,7 +1617,7 @@ public final class PushProcessMessageJob extends BaseJob {
}
}
return Optional.of(new QuoteModel(quote.get().getId(), author, message.getBody(), false, attachments));
return Optional.of(new QuoteModel(quote.get().getId(), author, message.getBody(), false, attachments, mentions));
} else if (message != null) {
Log.w(TAG, "Found the target for the quote, but it's flagged as remotely deleted.");
}
@ -1606,7 +1628,8 @@ public final class PushProcessMessageJob extends BaseJob {
author,
quote.get().getText(),
true,
PointerAttachment.forPointers(quote.get().getAttachments())));
PointerAttachment.forPointers(quote.get().getAttachments()),
getMentions(quote.get().getMentions())));
}
private Optional<Attachment> getStickerAttachment(Optional<SignalServiceDataMessage.Sticker> sticker) {
@ -1685,6 +1708,26 @@ public final class PushProcessMessageJob extends BaseJob {
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) {
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.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.jobmanager.Job;
@ -239,6 +240,7 @@ public abstract class PushSendJob extends SendJob {
long quoteId = message.getOutgoingQuote().getId();
String quoteBody = message.getOutgoingQuote().getText();
RecipientId quoteAuthor = message.getOutgoingQuote().getAuthor();
List<SignalServiceDataMessage.Mention> quoteMentions = getMentionsFor(message.getOutgoingQuote().getMentions());
List<SignalServiceDataMessage.Quote.QuotedAttachment> quoteAttachments = new LinkedList<>();
List<Attachment> filteredAttachments = Stream.of(message.getOutgoingQuote().getAttachments())
.filterNot(a -> MediaUtil.isViewOnceType(a.getContentType()))
@ -284,7 +286,7 @@ public abstract class PushSendJob extends SendJob {
Recipient quoteAuthorRecipient = Recipient.resolved(quoteAuthor);
SignalServiceAddress quoteAddress = RecipientUtil.toSignalServiceAddress(context, quoteAuthorRecipient);
return Optional.of(new SignalServiceDataMessage.Quote(quoteId, quoteAddress, quoteBody, quoteAttachments));
return Optional.of(new SignalServiceDataMessage.Quote(quoteId, quoteAddress, quoteBody, quoteAttachments, quoteMentions));
}
protected Optional<SignalServiceDataMessage.Sticker> getStickerFor(OutgoingMediaMessage message) {
@ -334,6 +336,12 @@ public abstract class PushSendJob extends SendJob {
}).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 {
try {
byte[] certificateBytes = TextSecurePreferences.getUnidentifiedAccessCertificate(context);

View File

@ -98,7 +98,7 @@ public final class WakeGroupV2Job extends BaseJob {
GroupDatabase.V2GroupProperties v2GroupProperties = group.get().requireV2GroupProperties();
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(v2GroupProperties.getGroupMasterKey(), v2GroupProperties.getDecryptedGroup(), null, null);
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, decryptedGroupV2Context, null, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList());
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, decryptedGroupV2Context, null, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
long messageId = mmsDatabase.insertMessageOutbox(outgoingMessage, threadId, false, null);

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

View File

@ -1,28 +1,33 @@
package org.thoughtcrime.securesms.longmessage;
import android.content.Context;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
/**
* A wrapper around a {@link MessageRecord} and its extra text attachment expanded into a string
* A wrapper around a {@link ConversationMessage} and its extra text attachment expanded into a string
* held in memory.
*/
class LongMessage {
private final MessageRecord messageRecord;
private final String fullBody;
private final ConversationMessage conversationMessage;
private final String fullBody;
LongMessage(MessageRecord messageRecord, String fullBody) {
this.messageRecord = messageRecord;
this.fullBody = fullBody;
LongMessage(@NonNull ConversationMessage conversationMessage, @NonNull String fullBody) {
this.conversationMessage = conversationMessage;
this.fullBody = fullBody;
}
MessageRecord getMessageRecord() {
return messageRecord;
@NonNull MessageRecord getMessageRecord() {
return conversationMessage.getMessageRecord();
}
String getFullBody() {
return !TextUtils.isEmpty(fullBody) ? fullBody : messageRecord.getBody();
@NonNull CharSequence getFullBody(@NonNull Context context) {
return !TextUtils.isEmpty(fullBody) ? fullBody : conversationMessage.getDisplayBody(context);
}
}

View File

@ -147,7 +147,7 @@ public class LongMessageActivity extends PassphraseRequiredActivity {
TextView text = bubble.findViewById(R.id.longmessage_text);
ConversationItemFooter footer = bubble.findViewById(R.id.longmessage_footer);
String trimmedBody = getTrimmedBody(message.get().getFullBody());
CharSequence trimmedBody = getTrimmedBody(message.get().getFullBody(this));
SpannableString styledBody = linkifyMessageBody(new SpannableString(trimmedBody));
bubble.setVisibility(View.VISIBLE);
@ -158,9 +158,9 @@ public class LongMessageActivity extends PassphraseRequiredActivity {
});
}
private String getTrimmedBody(@NonNull String text) {
private CharSequence getTrimmedBody(@NonNull CharSequence text) {
return text.length() <= MAX_DISPLAY_LENGTH ? text
: text.substring(0, MAX_DISPLAY_LENGTH);
: text.subSequence(0, MAX_DISPLAY_LENGTH);
}
private SpannableString linkifyMessageBody(SpannableString messageBody) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
@ -45,7 +46,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
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) {
((RecipientHeaderViewHolder) holder).bind((RecipientHeader) getItem(position).data);
} else if (holder instanceof RecipientViewHolder) {
@ -60,7 +61,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
if (payloads.isEmpty()) {
super.onBindViewHolder(holder, position, payloads);
} 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.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.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
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) {

View File

@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
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);
}
void bind(MessageRecord messageRecord, boolean running) {
bindMessageView(messageRecord);
void bind(ConversationMessage conversationMessage, boolean running) {
MessageRecord messageRecord = conversationMessage.getMessageRecord();
bindMessageView(conversationMessage);
bindErrorState(messageRecord);
bindSentReceivedDates(messageRecord);
bindExpirationTime(messageRecord, running);
bindTransport(messageRecord);
}
void partialBind(MessageRecord messageRecord, boolean running) {
bindExpirationTime(messageRecord, running);
void partialBind(ConversationMessage conversationMessage, boolean running) {
bindExpirationTime(conversationMessage.getMessageRecord(), running);
}
private void bindMessageView(MessageRecord messageRecord) {
private void bindMessageView(ConversationMessage conversationMessage) {
if (conversationItem == null) {
if (messageRecord.isGroupAction()) conversationItem = (ConversationItem) updateStub.inflate();
else if (messageRecord.isOutgoing()) conversationItem = (ConversationItem) sentStub.inflate();
else conversationItem = (ConversationItem) receivedStub.inflate();
if (conversationMessage.getMessageRecord().isGroupAction()) {
conversationItem = (ConversationItem) updateStub.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) {

View File

@ -377,7 +377,7 @@ public class AttachmentManager {
.execute();
}
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull String body, @NonNull TransportOption transport) {
public static void selectGallery(Activity activity, int requestCode, @NonNull Recipient recipient, @NonNull CharSequence body, @NonNull TransportOption transport) {
Permissions.with(activity)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,7 +75,20 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver {
if (recipient.resolve().isGroup()) {
Log.w(TAG, "GroupRecipient, Sending media message");
OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, false, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient,
responseText.toString(),
new LinkedList<>(),
System.currentTimeMillis(),
subscriptionId,
expiresIn,
false,
0,
null,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList());
replyThreadId = MessageSender.send(context, reply, threadId, false, null);
} else {
Log.w(TAG, "Sending regular message ");

View File

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

View File

@ -77,7 +77,20 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
switch (replyMethod) {
case GroupMessage: {
OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient, responseText.toString(), new LinkedList<>(), System.currentTimeMillis(), subscriptionId, expiresIn, false, 0, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
OutgoingMediaMessage reply = new OutgoingMediaMessage(recipient,
responseText.toString(),
new LinkedList<>(),
System.currentTimeMillis(),
subscriptionId,
expiresIn,
false,
0,
null,
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList(),
Collections.emptyList());
threadId = MessageSender.send(context, reply, -1, false, null);
break;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -180,4 +180,21 @@ public final class StringUtil {
.appendCodePoint(Bidi.PDI)
.toString();
}
/**
* Trims a {@link CharSequence} of starting and trailing whitespace. Behavior matches
* {@link String#trim()} to preserve expectations around results.
*/
public static CharSequence trimSequence(CharSequence text) {
int length = text.length();
int startIndex = 0;
while ((startIndex < length) && (text.charAt(startIndex) <= ' ')) {
startIndex++;
}
while ((startIndex < length) && (text.charAt(length - 1) <= ' ')) {
length--;
}
return (startIndex > 0 || length < text.length()) ? text.subSequence(startIndex, length) : text;
}
}

View File

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

View File

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

View File

@ -290,6 +290,46 @@
</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>
</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: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
android:id="@+id/mediasend_caption"
android:layout_width="match_parent"
@ -117,7 +124,7 @@
android:layout_marginStart="12dp"
android:layout_gravity="bottom"
android:background="@drawable/circle_tintable"
tools:backgroundTint="@color/core_blue">
tools:backgroundTint="@color/core_ultramarine">
<org.thoughtcrime.securesms.components.SendButton
android:id="@+id/mediasend_send_button"

View File

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

View File

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

View File

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

View File

@ -90,6 +90,7 @@
<attr name="conversation_title_color" format="reference" />
<attr name="conversation_subtitle_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_indicator" format="color" />
@ -404,6 +405,7 @@
<attr name="scaleEmojis" format="boolean" />
<attr name="emoji_maxLength" format="integer" />
<attr name="emoji_forceCustom" format="boolean" />
<attr name="emoji_renderMentions" format="boolean" />
</declare-styleable>
<declare-styleable name="RingtonePreference">

View File

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

View File

@ -549,6 +549,7 @@
<string name="ManageGroupActivity_leave_group">Leave group</string>
<string name="ManageGroupActivity_mute_notifications">Mute 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_off">Off</string>
<string name="ManageGroupActivity_on">On</string>
@ -576,6 +577,13 @@
<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>
<!-- 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 -->
<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_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_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 -->
<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_subtitle_color">@color/transparent_white_90</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_text_color">@color/core_ultramarine</item>
@ -622,6 +623,7 @@
<item name="conversation_title_color">@color/transparent_white_90</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_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_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());
if (message.getQuote().get().getAuthor().getUuid().isPresent()) {
quoteBuilder = quoteBuilder.setAuthorUuid(message.getQuote().get().getAuthor().getUuid().get().toString());
quoteBuilder.setAuthorUuid(message.getQuote().get().getAuthor().getUuid().get().toString());
}
if (message.getQuote().get().getAuthor().getNumber().isPresent()) {
quoteBuilder = quoteBuilder.setAuthorE164(message.getQuote().get().getAuthor().getNumber().get());
quoteBuilder.setAuthorE164(message.getQuote().get().getAuthor().getNumber().get());
}
if (!message.getQuote().get().getMentions().isEmpty()) {
for (SignalServiceDataMessage.Mention mention : message.getQuote().get().getMentions()) {
quoteBuilder.addBodyRanges(DataMessage.BodyRange.newBuilder()
.setStart(mention.getStart())
.setLength(mention.getLength())
.setMentionUuid(mention.getUuid().toString()));
}
builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.MENTIONS_VALUE, builder.getRequiredProtocolVersion()));
}
for (SignalServiceDataMessage.Quote.QuotedAttachment attachment : message.getQuote().get().getAttachments()) {
@ -626,6 +637,16 @@ public class SignalServiceMessageSender {
}
}
if (message.getMentions().isPresent()) {
for (SignalServiceDataMessage.Mention mention : message.getMentions().get()) {
builder.addBodyRanges(DataMessage.BodyRange.newBuilder()
.setStart(mention.getStart())
.setLength(mention.getLength())
.setMentionUuid(mention.getUuid().toString()));
}
builder.setRequiredProtocolVersion(Math.max(DataMessage.ProtocolVersion.MENTIONS_VALUE, builder.getRequiredProtocolVersion()));
}
if (message.getSticker().isPresent()) {
DataMessage.Sticker.Builder stickerBuilder = DataMessage.Sticker.newBuilder();

View File

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

View File

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

View File

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