Add text formatting send and receive support for conversations.

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