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

View File

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

View File

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

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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