Move reactions into their own table.

This commit is contained in:
Greyson Parrelli
2021-11-11 13:12:51 -05:00
committed by Cody Henthorne
parent 3a1f06f510
commit ab55fec6bd
32 changed files with 650 additions and 567 deletions

View File

@@ -167,6 +167,7 @@ import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.identity.IdentityRecordList;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
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.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
@@ -2361,9 +2362,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
.orElse(null);
if (oldRecord != null && oldRecord.getEmoji().equals(emoji)) {
MessageSender.sendReactionRemoval(context, messageRecord.getId(), messageRecord.isMms(), oldRecord);
MessageSender.sendReactionRemoval(context, new MessageId(messageRecord.getId(), messageRecord.isMms()), oldRecord);
} else {
MessageSender.sendNewReaction(context, messageRecord.getId(), messageRecord.isMms(), emoji);
MessageSender.sendNewReaction(context, new MessageId(messageRecord.getId(), messageRecord.isMms()), emoji);
}
});
}
@@ -2381,8 +2382,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
reactionDelegate.hide();
SignalExecutors.BOUNDED.execute(() -> MessageSender.sendReactionRemoval(context,
messageRecord.getId(),
messageRecord.isMms(),
new MessageId(messageRecord.getId(), messageRecord.isMms()),
oldRecord));
} else {
reactionDelegate.hideForReactWithAny();

View File

@@ -13,7 +13,6 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.conversation.ConversationData.MessageRequestData;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
@@ -21,6 +20,8 @@ import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
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.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.Util;
@@ -72,12 +73,14 @@ class ConversationDataSource implements PagedDataSource<MessageId, ConversationM
List<MessageRecord> records = new ArrayList<>(length);
MentionHelper mentionHelper = new MentionHelper();
AttachmentHelper attachmentHelper = new AttachmentHelper();
ReactionHelper reactionHelper = new ReactionHelper();
try (MmsSmsDatabase.Reader reader = MmsSmsDatabase.readerFor(db.getConversation(threadId, start, length))) {
MessageRecord record;
while ((record = reader.getNext()) != null && !cancellationSignal.isCanceled()) {
records.add(record);
mentionHelper.add(record);
reactionHelper.add(record);
attachmentHelper.add(record);
}
}
@@ -93,15 +96,18 @@ class ConversationDataSource implements PagedDataSource<MessageId, ConversationM
stopwatch.split("messages");
mentionHelper.fetchMentions(context);
stopwatch.split("mentions");
attachmentHelper.fetchAttachments(context);
reactionHelper.fetchReactions(context);
stopwatch.split("reactions");
records = reactionHelper.buildUpdatedModels(context, records);
stopwatch.split("reaction-models");
attachmentHelper.fetchAttachments(context);
stopwatch.split("attachments");
records = attachmentHelper.buildUpdatedModels(context, records);
stopwatch.split("attachment-models");
List<ConversationMessage> messages = Stream.of(records)
@@ -133,6 +139,11 @@ class ConversationDataSource implements PagedDataSource<MessageId, ConversationM
stopwatch.split("mentions");
List<ReactionRecord> reactions = DatabaseFactory.getReactionDatabase(context).getReactions(messageId);
record = ReactionHelper.recordWithReactions(record, reactions);
stopwatch.split("reactions");
if (messageId.isMms()) {
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachmentsForMessage(messageId.getId());
if (attachments.size() > 0) {
@@ -208,4 +219,43 @@ class ConversationDataSource implements PagedDataSource<MessageId, ConversationM
}
}
private static class ReactionHelper {
private Collection<MessageId> messageIds = new LinkedList<>();
private Map<MessageId, List<ReactionRecord>> messageIdToReactions = new HashMap<>();
void add(MessageRecord record) {
messageIds.add(new MessageId(record.getId(), record.isMms()));
}
void fetchReactions(Context context) {
messageIdToReactions = DatabaseFactory.getReactionDatabase(context).getReactionsForMessages(messageIds);
}
@NonNull List<MessageRecord> buildUpdatedModels(@NonNull Context context, @NonNull List<MessageRecord> records) {
return records.stream()
.map(record -> {
MessageId messageId = new MessageId(record.getId(), record.isMms());
List<ReactionRecord> reactions = messageIdToReactions.get(messageId);
return recordWithReactions(record, reactions);
})
.collect(Collectors.toList());
}
static MessageRecord recordWithReactions(@NonNull MessageRecord record, List<ReactionRecord> reactions) {
if (Util.hasItems(reactions)) {
if (record instanceof MediaMmsMessageRecord) {
return ((MediaMmsMessageRecord) record).withReactions(reactions);
} else if (record instanceof SmsMessageRecord) {
return ((SmsMessageRecord) record).withReactions(reactions);
} else {
throw new IllegalStateException("We have reactions for an unsupported record type: " + record.getClass().getName());
}
} else {
return record;
}
}
}
}

View File

@@ -76,6 +76,7 @@ public class DatabaseFactory {
private final MessageSendLogDatabase messageSendLogDatabase;
private final AvatarPickerDatabase avatarPickerDatabase;
private final GroupCallRingDatabase groupCallRingDatabase;
private final ReactionDatabase reactionDatabase;
public static DatabaseFactory getInstance(Context context) {
if (instance == null) {
@@ -214,6 +215,10 @@ public class DatabaseFactory {
return getInstance(context).groupCallRingDatabase;
}
public static ReactionDatabase getReactionDatabase(Context context) {
return getInstance(context).reactionDatabase;
}
public static net.zetetic.database.sqlcipher.SQLiteDatabase getBackupDatabase(Context context) {
return getInstance(context).databaseHelper.getRawReadableDatabase();
}
@@ -274,6 +279,7 @@ public class DatabaseFactory {
this.messageSendLogDatabase = new MessageSendLogDatabase(context, databaseHelper);
this.avatarPickerDatabase = new AvatarPickerDatabase(context, databaseHelper);
this.groupCallRingDatabase = new GroupCallRingDatabase(context, databaseHelper);
this.reactionDatabase = new ReactionDatabase(context, databaseHelper);
}
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,

View File

@@ -310,73 +310,6 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
db.update(getTableName(), values, query, args);
}
public void addReaction(long messageId, @NonNull ReactionRecord reaction) {
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
db.beginTransaction();
try {
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
ReactionList.Reaction newReaction = ReactionList.Reaction.newBuilder()
.setEmoji(reaction.getEmoji())
.setAuthor(reaction.getAuthor().toLong())
.setSentTime(reaction.getDateSent())
.setReceivedTime(reaction.getDateReceived())
.build();
ReactionList updatedList = pruneByAuthor(reactions, reaction.getAuthor()).toBuilder()
.addReactions(newReaction)
.build();
setReactions(db, messageId, updatedList);
db.setTransactionSuccessful();
} catch (NoSuchMessageException e) {
Log.w(TAG, "No message for provided id", e);
} finally {
db.endTransaction();
}
notifyConversationListeners(getThreadId(db, messageId));
}
public void deleteReaction(long messageId, @NonNull RecipientId author) {
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
db.beginTransaction();
try {
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
ReactionList updatedList = pruneByAuthor(reactions, author);
setReactions(db, messageId, updatedList);
db.setTransactionSuccessful();
} catch (NoSuchMessageException e) {
Log.w(TAG, "No message for provided id", e);
} finally {
db.endTransaction();
}
notifyConversationListeners(getThreadId(db, messageId));
}
public boolean hasReaction(long messageId, @NonNull ReactionRecord reactionRecord) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
ReactionList reactions = getReactions(db, messageId).or(ReactionList.getDefaultInstance());
for (ReactionList.Reaction reaction : reactions.getReactionsList()) {
if (reactionRecord.getAuthor().toLong() == reaction.getAuthor() &&
reactionRecord.getEmoji().equals(reaction.getEmoji()))
{
return true;
}
}
return false;
}
public void setNotifiedTimestamp(long timestamp, @NonNull List<Long> ids) {
if (ids.isEmpty()) {
return;
@@ -438,25 +371,26 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
return data;
}
protected static List<ReactionRecord> parseReactions(@NonNull Cursor cursor) {
byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS));
void updateReactionsUnread(SQLiteDatabase db, long messageId, boolean hasReactions, boolean isRemoval) {
try {
boolean isOutgoing = getMessageRecord(messageId).isOutgoing();
ContentValues values = new ContentValues();
if (raw != null) {
try {
return Stream.of(ReactionList.parseFrom(raw).getReactionsList())
.map(r -> {
return new ReactionRecord(r.getEmoji(),
RecipientId.from(r.getAuthor()),
r.getSentTime(),
r.getReceivedTime());
})
.toList();
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, "[parseReactions] Failed to parse reaction list!", e);
return Collections.emptyList();
if (!hasReactions) {
values.put(REACTIONS_UNREAD, 0);
} else if (!isRemoval) {
values.put(REACTIONS_UNREAD, 1);
}
} else {
return Collections.emptyList();
if (isOutgoing && hasReactions) {
values.put(NOTIFIED, 0);
}
if (values.size() > 0) {
db.update(getTableName(), values, ID_WHERE, SqlUtil.buildArgs(messageId));
}
} catch (NoSuchMessageException e) {
Log.w(TAG, "Failed to find message " + messageId);
}
}
@@ -555,55 +489,6 @@ public abstract class MessageDatabase extends Database implements MmsSmsColumns
}
}
private static @NonNull ReactionList pruneByAuthor(@NonNull ReactionList reactionList, @NonNull RecipientId recipientId) {
List<ReactionList.Reaction> pruned = Stream.of(reactionList.getReactionsList())
.filterNot(r -> r.getAuthor() == recipientId.toLong())
.toList();
return reactionList.toBuilder()
.clearReactions()
.addAllReactions(pruned)
.build();
}
private @NonNull Optional<ReactionList> getReactions(SQLiteDatabase db, long messageId) {
String[] projection = new String[]{ REACTIONS };
String query = ID + " = ?";
String[] args = new String[]{String.valueOf(messageId)};
try (Cursor cursor = db.query(getTableName(), projection, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(REACTIONS));
if (raw != null) {
return Optional.of(ReactionList.parseFrom(raw));
}
}
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, "[getRecipients] Failed to parse reaction list!", e);
}
return Optional.absent();
}
private void setReactions(@NonNull SQLiteDatabase db, long messageId, @NonNull ReactionList reactionList) throws NoSuchMessageException {
ContentValues values = new ContentValues();
boolean isOutgoing = getMessageRecord(messageId).isOutgoing();
boolean hasReactions = reactionList.getReactionsCount() != 0;
values.put(REACTIONS, reactionList.getReactionsList().isEmpty() ? null : reactionList.toByteArray());
values.put(REACTIONS_UNREAD, hasReactions ? 1 : 0);
if (isOutgoing && hasReactions) {
values.put(NOTIFIED, 0);
}
String query = ID + " = ?";
String[] args = new String[] { String.valueOf(messageId) };
db.update(getTableName(), values, query, args);
}
private long getThreadId(@NonNull SQLiteDatabase db, long messageId) {
String[] projection = new String[]{ THREAD_ID };
String query = ID + " = ?";

View File

@@ -163,7 +163,6 @@ public class MmsDatabase extends MessageDatabase {
UNIDENTIFIED + " INTEGER DEFAULT 0, " +
LINK_PREVIEWS + " TEXT, " +
VIEW_ONCE + " INTEGER DEFAULT 0, " +
REACTIONS + " BLOB DEFAULT NULL, " +
REACTIONS_UNREAD + " INTEGER DEFAULT 0, " +
REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " +
REMOTE_DELETED + " INTEGER DEFAULT 0, " +
@@ -193,7 +192,7 @@ public class MmsDatabase extends MessageDatabase {
BODY, PART_COUNT, RECIPIENT_ID, ADDRESS_DEVICE_ID,
DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID,
EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, QUOTE_MENTIONS,
SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN,
SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS_UNREAD, REACTIONS_LAST_SEEN,
REMOTE_DELETED, MENTIONS_SELF, NOTIFIED_TIMESTAMP, VIEWED_RECEIPT_COUNT, RECEIPT_TIMESTAMP,
"json_group_array(json_object(" +
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
@@ -855,7 +854,6 @@ public class MmsDatabase extends MessageDatabase {
values.putNull(QUOTE_ID);
values.putNull(LINK_PREVIEWS);
values.putNull(SHARED_CONTACTS);
values.putNull(REACTIONS);
db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(messageId) });
DatabaseFactory.getAttachmentDatabase(context).deleteAttachmentsForMessage(messageId);
@@ -2099,7 +2097,6 @@ public class MmsDatabase extends MessageDatabase {
boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.UNIDENTIFIED)) == 1;
boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1;
boolean remoteDelete = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REMOTE_DELETED)) == 1;
List<ReactionRecord> reactions = parseReactions(cursor);
boolean mentionsSelf = CursorUtil.requireBoolean(cursor, MENTIONS_SELF);
long notifiedTimestamp = CursorUtil.requireLong(cursor, NOTIFIED_TIMESTAMP);
int viewedReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.VIEWED_RECEIPT_COUNT));
@@ -2128,7 +2125,7 @@ public class MmsDatabase extends MessageDatabase {
addressDeviceId, dateSent, dateReceived, dateServer, deliveryReceiptCount,
threadId, body, slideDeck, partCount, box, mismatches,
networkFailures, subscriptionId, expiresIn, expireStarted,
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions,
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(),
remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp);
}

View File

@@ -24,7 +24,6 @@ public interface MmsSmsColumns {
public static final String NOTIFIED = "notified";
public static final String NOTIFIED_TIMESTAMP = "notified_timestamp";
public static final String UNIDENTIFIED = "unidentified";
public static final String REACTIONS = "reactions";
public static final String REACTIONS_UNREAD = "reactions_unread";
public static final String REACTIONS_LAST_SEEN = "reactions_last_seen";
public static final String REMOTE_DELETED = "remote_deleted";

View File

@@ -74,7 +74,6 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.MESSAGE_BOX,
SmsDatabase.STATUS,
MmsSmsColumns.UNIDENTIFIED,
MmsSmsColumns.REACTIONS,
MmsDatabase.PART_COUNT,
MmsDatabase.CONTENT_LOCATION,
MmsDatabase.TRANSACTION_ID,
@@ -101,7 +100,6 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE,
MmsSmsColumns.READ,
MmsSmsColumns.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.REMOTE_DELETED,
@@ -678,7 +676,6 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE,
MmsDatabase.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.DATE_SERVER,
@@ -712,7 +709,6 @@ public class MmsSmsDatabase extends Database {
MmsDatabase.SHARED_CONTACTS,
MmsDatabase.LINK_PREVIEWS,
MmsDatabase.VIEW_ONCE,
MmsDatabase.REACTIONS,
MmsSmsColumns.REACTIONS_UNREAD,
MmsSmsColumns.REACTIONS_LAST_SEEN,
MmsSmsColumns.DATE_SERVER,
@@ -775,7 +771,6 @@ public class MmsSmsDatabase extends Database {
mmsColumnsPresent.add(MmsDatabase.SHARED_CONTACTS);
mmsColumnsPresent.add(MmsDatabase.LINK_PREVIEWS);
mmsColumnsPresent.add(MmsDatabase.VIEW_ONCE);
mmsColumnsPresent.add(MmsDatabase.REACTIONS);
mmsColumnsPresent.add(MmsDatabase.REACTIONS_UNREAD);
mmsColumnsPresent.add(MmsDatabase.REACTIONS_LAST_SEEN);
mmsColumnsPresent.add(MmsDatabase.REMOTE_DELETED);
@@ -805,7 +800,6 @@ public class MmsSmsDatabase extends Database {
smsColumnsPresent.add(SmsDatabase.DATE_SERVER);
smsColumnsPresent.add(SmsDatabase.STATUS);
smsColumnsPresent.add(SmsDatabase.UNIDENTIFIED);
smsColumnsPresent.add(SmsDatabase.REACTIONS);
smsColumnsPresent.add(SmsDatabase.REACTIONS_UNREAD);
smsColumnsPresent.add(SmsDatabase.REACTIONS_LAST_SEEN);
smsColumnsPresent.add(MmsDatabase.REMOTE_DELETED);

View File

@@ -0,0 +1,199 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.CursorUtil
import org.thoughtcrime.securesms.util.SqlUtil
/**
* Store reactions on messages.
*/
class ReactionDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Database(context, databaseHelper) {
companion object {
private const val TABLE_NAME = "reaction"
private const val ID = "_id"
private const val MESSAGE_ID = "message_id"
private const val IS_MMS = "is_mms"
private const val AUTHOR_ID = "author_id"
private const val EMOJI = "emoji"
private const val DATE_SENT = "date_sent"
private const val DATE_RECEIVED = "date_received"
@JvmField
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$MESSAGE_ID INTEGER NOT NULL,
$IS_MMS INTEGER NOT NULL,
$AUTHOR_ID INTEGER NOT NULL REFERENCES ${RecipientDatabase.TABLE_NAME} (${RecipientDatabase.ID}) ON DELETE CASCADE,
$EMOJI TEXT NOT NULL,
$DATE_SENT INTEGER NOT NULL,
$DATE_RECEIVED INTEGER NOT NULL,
UNIQUE($MESSAGE_ID, $IS_MMS, $AUTHOR_ID) ON CONFLICT REPLACE
)
""".trimIndent()
@JvmField
val CREATE_TRIGGERS = arrayOf(
"""
CREATE TRIGGER reactions_sms_delete AFTER DELETE ON ${SmsDatabase.TABLE_NAME}
BEGIN
DELETE FROM $TABLE_NAME WHERE $MESSAGE_ID = old.${SmsDatabase.ID} AND $IS_MMS = 0;
END
""",
"""
CREATE TRIGGER reactions_mms_delete AFTER DELETE ON ${MmsDatabase.TABLE_NAME}
BEGIN
DELETE FROM $TABLE_NAME WHERE $MESSAGE_ID = old.${MmsDatabase.ID} AND $IS_MMS = 1;
END
"""
)
private fun readReaction(cursor: Cursor): ReactionRecord {
return ReactionRecord(
emoji = CursorUtil.requireString(cursor, EMOJI),
author = RecipientId.from(CursorUtil.requireLong(cursor, AUTHOR_ID)),
dateSent = CursorUtil.requireLong(cursor, DATE_SENT),
dateReceived = CursorUtil.requireLong(cursor, DATE_RECEIVED)
)
}
}
fun getReactions(messageId: MessageId): List<ReactionRecord> {
val query = "$MESSAGE_ID = ? AND $IS_MMS = ?"
val args = SqlUtil.buildArgs(messageId.id, if (messageId.mms) 1 else 0)
val reactions: MutableList<ReactionRecord> = mutableListOf()
databaseHelper.signalReadableDatabase.query(TABLE_NAME, null, query, args, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
reactions += readReaction(cursor)
}
}
return reactions
}
fun getReactionsForMessages(messageIds: Collection<MessageId>): Map<MessageId, List<ReactionRecord>> {
if (messageIds.isEmpty()) {
return emptyMap()
}
val messageIdToReactions: MutableMap<MessageId, MutableList<ReactionRecord>> = mutableMapOf()
val args: List<Array<String>> = messageIds.map { SqlUtil.buildArgs(it.id, if (it.mms) 1 else 0) }
for (query: SqlUtil.Query in SqlUtil.buildCustomCollectionQuery("$MESSAGE_ID = ? AND $IS_MMS = ?", args)) {
databaseHelper.signalReadableDatabase.query(TABLE_NAME, null, query.where, query.whereArgs, null, null, null).use { cursor ->
while (cursor.moveToNext()) {
val reaction: ReactionRecord = readReaction(cursor)
val messageId = MessageId(
id = CursorUtil.requireLong(cursor, MESSAGE_ID),
mms = CursorUtil.requireBoolean(cursor, IS_MMS)
)
var reactionsList: MutableList<ReactionRecord>? = messageIdToReactions[messageId]
if (reactionsList == null) {
reactionsList = mutableListOf()
messageIdToReactions[messageId] = reactionsList
}
reactionsList.add(reaction)
}
}
}
return messageIdToReactions
}
fun addReaction(messageId: MessageId, reaction: ReactionRecord) {
val db: SQLiteDatabase = databaseHelper.signalWritableDatabase
db.beginTransaction()
try {
val values = ContentValues().apply {
put(MESSAGE_ID, messageId.id)
put(IS_MMS, if (messageId.mms) 1 else 0)
put(EMOJI, reaction.emoji)
put(AUTHOR_ID, reaction.author.serialize())
put(DATE_SENT, reaction.dateSent)
put(DATE_RECEIVED, reaction.dateReceived)
}
db.insert(TABLE_NAME, null, values)
if (messageId.mms) {
DatabaseFactory.getMmsDatabase(context).updateReactionsUnread(db, messageId.id, hasReactions(messageId), false)
} else {
DatabaseFactory.getSmsDatabase(context).updateReactionsUnread(db, messageId.id, hasReactions(messageId), false)
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(messageId)
}
fun deleteReaction(messageId: MessageId, recipientId: RecipientId) {
val db: SQLiteDatabase = databaseHelper.signalWritableDatabase
db.beginTransaction()
try {
val query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $AUTHOR_ID = ?"
val args = SqlUtil.buildArgs(messageId.id, if (messageId.mms) 1 else 0, recipientId)
db.delete(TABLE_NAME, query, args)
if (messageId.mms) {
DatabaseFactory.getMmsDatabase(context).updateReactionsUnread(db, messageId.id, hasReactions(messageId), true)
} else {
DatabaseFactory.getSmsDatabase(context).updateReactionsUnread(db, messageId.id, hasReactions(messageId), true)
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(messageId)
}
fun hasReaction(messageId: MessageId, reaction: ReactionRecord): Boolean {
val query = "$MESSAGE_ID = ? AND $IS_MMS = ? AND $AUTHOR_ID = ? AND $EMOJI = ?"
val args = SqlUtil.buildArgs(messageId.id, if (messageId.mms) 1 else 0, reaction.author, reaction.emoji)
databaseHelper.signalReadableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor ->
return cursor.moveToFirst()
}
}
private fun hasReactions(messageId: MessageId): Boolean {
val query = "$MESSAGE_ID = ? AND $IS_MMS = ?"
val args = SqlUtil.buildArgs(messageId.id, if (messageId.mms) 1 else 0)
databaseHelper.signalReadableDatabase.query(TABLE_NAME, arrayOf(MESSAGE_ID), query, args, null, null, null).use { cursor ->
return cursor.moveToFirst()
}
}
fun remapRecipient(oldAuthorId: RecipientId, newAuthorId: RecipientId) {
val query = "$AUTHOR_ID = ?"
val args = SqlUtil.buildArgs(oldAuthorId)
val values = ContentValues().apply {
put(AUTHOR_ID, newAuthorId.serialize())
}
databaseHelper.signalWritableDatabase.update(TABLE_NAME, values, query, args)
}
}

View File

@@ -3019,43 +3019,6 @@ public class RecipientDatabase extends Database {
RecipientSettings uuidSettings = getRecipientSettings(byUuid);
RecipientSettings e164Settings = getRecipientSettings(byE164);
// Recipient
Log.w(TAG, "Deleting recipient " + byE164, true);
db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164));
RemappedRecords.getInstance().addRecipient(context, byE164, byUuid);
ContentValues uuidValues = new ContentValues();
uuidValues.put(PHONE, e164Settings.getE164());
uuidValues.put(BLOCKED, e164Settings.isBlocked() || uuidSettings.isBlocked());
uuidValues.put(MESSAGE_RINGTONE, Optional.fromNullable(uuidSettings.getMessageRingtone()).or(Optional.fromNullable(e164Settings.getMessageRingtone())).transform(Uri::toString).orNull());
uuidValues.put(MESSAGE_VIBRATE, uuidSettings.getMessageVibrateState() != VibrateState.DEFAULT ? uuidSettings.getMessageVibrateState().getId() : e164Settings.getMessageVibrateState().getId());
uuidValues.put(CALL_RINGTONE, Optional.fromNullable(uuidSettings.getCallRingtone()).or(Optional.fromNullable(e164Settings.getCallRingtone())).transform(Uri::toString).orNull());
uuidValues.put(CALL_VIBRATE, uuidSettings.getCallVibrateState() != VibrateState.DEFAULT ? uuidSettings.getCallVibrateState().getId() : e164Settings.getCallVibrateState().getId());
uuidValues.put(NOTIFICATION_CHANNEL, uuidSettings.getNotificationChannel() != null ? uuidSettings.getNotificationChannel() : e164Settings.getNotificationChannel());
uuidValues.put(MUTE_UNTIL, uuidSettings.getMuteUntil() > 0 ? uuidSettings.getMuteUntil() : e164Settings.getMuteUntil());
uuidValues.put(CHAT_COLORS, Optional.fromNullable(uuidSettings.getChatColors()).or(Optional.fromNullable(e164Settings.getChatColors())).transform(colors -> colors.serialize().toByteArray()).orNull());
uuidValues.put(AVATAR_COLOR, uuidSettings.getAvatarColor().serialize());
uuidValues.put(CUSTOM_CHAT_COLORS_ID, Optional.fromNullable(uuidSettings.getChatColors()).or(Optional.fromNullable(e164Settings.getChatColors())).transform(colors -> colors.getId().getLongValue()).orNull());
uuidValues.put(SEEN_INVITE_REMINDER, e164Settings.getInsightsBannerTier().getId());
uuidValues.put(DEFAULT_SUBSCRIPTION_ID, e164Settings.getDefaultSubscriptionId().or(-1));
uuidValues.put(MESSAGE_EXPIRATION_TIME, uuidSettings.getExpireMessages() > 0 ? uuidSettings.getExpireMessages() : e164Settings.getExpireMessages());
uuidValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
uuidValues.put(SYSTEM_GIVEN_NAME, e164Settings.getSystemProfileName().getGivenName());
uuidValues.put(SYSTEM_FAMILY_NAME, e164Settings.getSystemProfileName().getFamilyName());
uuidValues.put(SYSTEM_JOINED_NAME, e164Settings.getSystemProfileName().toString());
uuidValues.put(SYSTEM_PHOTO_URI, e164Settings.getSystemContactPhotoUri());
uuidValues.put(SYSTEM_PHONE_LABEL, e164Settings.getSystemPhoneLabel());
uuidValues.put(SYSTEM_CONTACT_URI, e164Settings.getSystemContactUri());
uuidValues.put(PROFILE_SHARING, uuidSettings.isProfileSharing() || e164Settings.isProfileSharing());
uuidValues.put(CAPABILITIES, Math.max(uuidSettings.getCapabilities(), e164Settings.getCapabilities()));
uuidValues.put(MENTION_SETTING, uuidSettings.getMentionSetting() != MentionSetting.ALWAYS_NOTIFY ? uuidSettings.getMentionSetting().getId() : e164Settings.getMentionSetting().getId());
if (uuidSettings.getProfileKey() != null) {
updateProfileValuesForMerge(uuidValues, uuidSettings);
} else if (e164Settings.getProfileKey() != null) {
updateProfileValuesForMerge(uuidValues, e164Settings);
}
db.update(TABLE_NAME, uuidValues, ID_WHERE, SqlUtil.buildArgs(byUuid));
// Identities
ApplicationDependencies.getIdentityStore().delete(e164Settings.e164);
@@ -3141,6 +3104,46 @@ public class RecipientDatabase extends Database {
DatabaseFactory.getThreadDatabase(context).setLastScrolled(threadMerge.threadId, 0);
DatabaseFactory.getThreadDatabase(context).update(threadMerge.threadId, false, false);
// Reactions
DatabaseFactory.getReactionDatabase(context).remapRecipient(byE164, byUuid);
// Recipient
Log.w(TAG, "Deleting recipient " + byE164, true);
db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(byE164));
RemappedRecords.getInstance().addRecipient(context, byE164, byUuid);
ContentValues uuidValues = new ContentValues();
uuidValues.put(PHONE, e164Settings.getE164());
uuidValues.put(BLOCKED, e164Settings.isBlocked() || uuidSettings.isBlocked());
uuidValues.put(MESSAGE_RINGTONE, Optional.fromNullable(uuidSettings.getMessageRingtone()).or(Optional.fromNullable(e164Settings.getMessageRingtone())).transform(Uri::toString).orNull());
uuidValues.put(MESSAGE_VIBRATE, uuidSettings.getMessageVibrateState() != VibrateState.DEFAULT ? uuidSettings.getMessageVibrateState().getId() : e164Settings.getMessageVibrateState().getId());
uuidValues.put(CALL_RINGTONE, Optional.fromNullable(uuidSettings.getCallRingtone()).or(Optional.fromNullable(e164Settings.getCallRingtone())).transform(Uri::toString).orNull());
uuidValues.put(CALL_VIBRATE, uuidSettings.getCallVibrateState() != VibrateState.DEFAULT ? uuidSettings.getCallVibrateState().getId() : e164Settings.getCallVibrateState().getId());
uuidValues.put(NOTIFICATION_CHANNEL, uuidSettings.getNotificationChannel() != null ? uuidSettings.getNotificationChannel() : e164Settings.getNotificationChannel());
uuidValues.put(MUTE_UNTIL, uuidSettings.getMuteUntil() > 0 ? uuidSettings.getMuteUntil() : e164Settings.getMuteUntil());
uuidValues.put(CHAT_COLORS, Optional.fromNullable(uuidSettings.getChatColors()).or(Optional.fromNullable(e164Settings.getChatColors())).transform(colors -> colors.serialize().toByteArray()).orNull());
uuidValues.put(AVATAR_COLOR, uuidSettings.getAvatarColor().serialize());
uuidValues.put(CUSTOM_CHAT_COLORS_ID, Optional.fromNullable(uuidSettings.getChatColors()).or(Optional.fromNullable(e164Settings.getChatColors())).transform(colors -> colors.getId().getLongValue()).orNull());
uuidValues.put(SEEN_INVITE_REMINDER, e164Settings.getInsightsBannerTier().getId());
uuidValues.put(DEFAULT_SUBSCRIPTION_ID, e164Settings.getDefaultSubscriptionId().or(-1));
uuidValues.put(MESSAGE_EXPIRATION_TIME, uuidSettings.getExpireMessages() > 0 ? uuidSettings.getExpireMessages() : e164Settings.getExpireMessages());
uuidValues.put(REGISTERED, RegisteredState.REGISTERED.getId());
uuidValues.put(SYSTEM_GIVEN_NAME, e164Settings.getSystemProfileName().getGivenName());
uuidValues.put(SYSTEM_FAMILY_NAME, e164Settings.getSystemProfileName().getFamilyName());
uuidValues.put(SYSTEM_JOINED_NAME, e164Settings.getSystemProfileName().toString());
uuidValues.put(SYSTEM_PHOTO_URI, e164Settings.getSystemContactPhotoUri());
uuidValues.put(SYSTEM_PHONE_LABEL, e164Settings.getSystemPhoneLabel());
uuidValues.put(SYSTEM_CONTACT_URI, e164Settings.getSystemContactUri());
uuidValues.put(PROFILE_SHARING, uuidSettings.isProfileSharing() || e164Settings.isProfileSharing());
uuidValues.put(CAPABILITIES, Math.max(uuidSettings.getCapabilities(), e164Settings.getCapabilities()));
uuidValues.put(MENTION_SETTING, uuidSettings.getMentionSetting() != MentionSetting.ALWAYS_NOTIFY ? uuidSettings.getMentionSetting().getId() : e164Settings.getMentionSetting().getId());
if (uuidSettings.getProfileKey() != null) {
updateProfileValuesForMerge(uuidValues, uuidSettings);
} else if (e164Settings.getProfileKey() != null) {
updateProfileValuesForMerge(uuidValues, e164Settings);
}
db.update(TABLE_NAME, uuidValues, ID_WHERE, SqlUtil.buildArgs(byUuid));
return byUuid;
}

View File

@@ -123,7 +123,6 @@ public class SmsDatabase extends MessageDatabase {
NOTIFIED + " DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " +
UNIDENTIFIED + " INTEGER DEFAULT 0, " +
REACTIONS + " BLOB DEFAULT NULL, " +
REACTIONS_UNREAD + " INTEGER DEFAULT 0, " +
REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " +
REMOTE_DELETED + " INTEGER DEFAULT 0, " +
@@ -148,7 +147,7 @@ public class SmsDatabase extends MessageDatabase {
PROTOCOL, READ, STATUS, TYPE,
REPLY_PATH_PRESENT, SUBJECT, BODY, SERVICE_CENTER, DELIVERY_RECEIPT_COUNT,
MISMATCHED_IDENTITIES, SUBSCRIPTION_ID, EXPIRES_IN, EXPIRE_STARTED,
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN,
NOTIFIED, READ_RECEIPT_COUNT, UNIDENTIFIED, REACTIONS_UNREAD, REACTIONS_LAST_SEEN,
REMOTE_DELETED, NOTIFIED_TIMESTAMP, RECEIPT_TIMESTAMP
};
@@ -391,7 +390,6 @@ public class SmsDatabase extends MessageDatabase {
ContentValues values = new ContentValues();
values.put(REMOTE_DELETED, 1);
values.putNull(BODY);
values.putNull(REACTIONS);
db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(id) });
threadId = getThreadIdForMessage(id);
@@ -1696,7 +1694,6 @@ public class SmsDatabase extends MessageDatabase {
String body = cursor.getString(cursor.getColumnIndexOrThrow(SmsDatabase.BODY));
boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.UNIDENTIFIED)) == 1;
boolean remoteDelete = cursor.getInt(cursor.getColumnIndexOrThrow(SmsDatabase.REMOTE_DELETED)) == 1;
List<ReactionRecord> reactions = parseReactions(cursor);
long notifiedTimestamp = CursorUtil.requireLong(cursor, NOTIFIED_TIMESTAMP);
long receiptTimestamp = CursorUtil.requireLong(cursor, RECEIPT_TIMESTAMP);
@@ -1713,7 +1710,7 @@ public class SmsDatabase extends MessageDatabase {
dateSent, dateReceived, dateServer, deliveryReceiptCount, type,
threadId, status, mismatches, subscriptionId,
expiresIn, expireStarted,
readReceiptCount, unidentified, reactions, remoteDelete,
readReceiptCount, unidentified, Collections.emptyList(), remoteDelete,
notifiedTimestamp, receiptTimestamp);
}

View File

@@ -44,6 +44,7 @@ import org.thoughtcrime.securesms.database.OneTimePreKeyDatabase;
import org.thoughtcrime.securesms.database.PaymentDatabase;
import org.thoughtcrime.securesms.database.PendingRetryReceiptDatabase;
import org.thoughtcrime.securesms.database.PushDatabase;
import org.thoughtcrime.securesms.database.ReactionDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RemappedRecordsDatabase;
import org.thoughtcrime.securesms.database.SearchDatabase;
@@ -219,8 +220,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
private static final int BADGES = 118;
private static final int SENDER_KEY_UUID = 119;
private static final int SENDER_KEY_SHARED_TIMESTAMP = 120;
private static final int REACTION_REFACTOR = 121;
private static final int DATABASE_VERSION = 120;
private static final int DATABASE_VERSION = 121;
private static final String DATABASE_NAME = "signal.db";
private final Context context;
@@ -263,6 +265,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
db.execSQL(EmojiSearchDatabase.CREATE_TABLE);
db.execSQL(AvatarPickerDatabase.CREATE_TABLE);
db.execSQL(GroupCallRingDatabase.CREATE_TABLE);
db.execSQL(ReactionDatabase.CREATE_TABLE);
executeStatements(db, SearchDatabase.CREATE_TABLE);
executeStatements(db, RemappedRecordsDatabase.CREATE_TABLE);
executeStatements(db, MessageSendLogDatabase.CREATE_TABLE);
@@ -283,6 +286,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
executeStatements(db, GroupCallRingDatabase.CREATE_INDEXES);
executeStatements(db, MessageSendLogDatabase.CREATE_TRIGGERS);
executeStatements(db, ReactionDatabase.CREATE_TRIGGERS);
if (context.getDatabasePath(ClassicOpenHelper.NAME).exists()) {
ClassicOpenHelper legacyHelper = new ClassicOpenHelper(context);
@@ -2054,12 +2058,12 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
long start = System.currentTimeMillis();
db.execSQL("CREATE TABLE sender_keys_tmp (_id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"address TEXT NOT NULL, " +
"device INTEGER NOT NULL, " +
"distribution_id TEXT NOT NULL, " +
"record BLOB NOT NULL, " +
"created_at INTEGER NOT NULL, " +
"UNIQUE(address, device, distribution_id) ON CONFLICT REPLACE)");
"address TEXT NOT NULL, " +
"device INTEGER NOT NULL, " +
"distribution_id TEXT NOT NULL, " +
"record BLOB NOT NULL, " +
"created_at INTEGER NOT NULL, " +
"UNIQUE(address, device, distribution_id) ON CONFLICT REPLACE)");
db.execSQL("INSERT INTO sender_keys_tmp (address, device, distribution_id, record, created_at) " +
"SELECT recipient.uuid AS new_address, " +
@@ -2080,6 +2084,37 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
db.execSQL("ALTER TABLE sender_key_shared ADD COLUMN timestamp INTEGER DEFAULT 0");
}
if (oldVersion < REACTION_REFACTOR) {
db.execSQL("CREATE TABLE reaction (_id INTEGER PRIMARY KEY, " +
"message_id INTEGER NOT NULL, " +
"is_mms INTEGER NOT NULL, " +
"author_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE, " +
"emoji TEXT NOT NULL, " +
"date_sent INTEGER NOT NULL, " +
"date_received INTEGER NOT NULL, " +
"UNIQUE(message_id, is_mms, author_id) ON CONFLICT REPLACE)");
try (Cursor cursor = db.rawQuery("SELECT _id, reactions FROM sms WHERE reactions NOT NULL", null)) {
while (cursor.moveToNext()) {
migrateReaction(db, cursor, false);
}
}
try (Cursor cursor = db.rawQuery("SELECT _id, reactions FROM mms WHERE reactions NOT NULL", null)) {
while (cursor.moveToNext()) {
migrateReaction(db, cursor, true);
}
}
db.execSQL("UPDATE reaction SET author_id = IFNULL((SELECT new_id FROM remapped_recipients WHERE author_id = old_id), author_id)");
db.execSQL("CREATE TRIGGER reactions_sms_delete AFTER DELETE ON sms BEGIN DELETE FROM reaction WHERE message_id = old._id AND is_mms = 0; END");
db.execSQL("CREATE TRIGGER reactions_mms_delete AFTER DELETE ON mms BEGIN DELETE FROM reaction WHERE message_id = old._id AND is_mms = 0; END");
db.execSQL("UPDATE sms SET reactions = NULL WHERE reactions NOT NULL");
db.execSQL("UPDATE mms SET reactions = NULL WHERE reactions NOT NULL");
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
@@ -2092,6 +2127,27 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
Log.i(TAG, "Upgrade complete. Took " + (System.currentTimeMillis() - startTime) + " ms.");
}
private void migrateReaction(@NonNull SQLiteDatabase db, @NonNull Cursor cursor, boolean isMms) {
try {
long messageId = CursorUtil.requireLong(cursor, "_id");
ReactionList reactionList = ReactionList.parseFrom(CursorUtil.requireBlob(cursor, "reactions"));
for (ReactionList.Reaction reaction : reactionList.getReactionsList()) {
ContentValues contentValues = new ContentValues();
contentValues.put("message_id", messageId);
contentValues.put("is_mms", isMms ? 1 : 0);
contentValues.put("author_id", reaction.getAuthor());
contentValues.put("emoji", reaction.getEmoji());
contentValues.put("date_sent", reaction.getSentTime());
contentValues.put("date_received", reaction.getReceivedTime());
db.insert("reaction", null, contentValues);
}
} catch (InvalidProtocolBufferException e) {
Log.w(TAG, "Failed to parse reaction!");
}
}
@Override
public net.zetetic.database.sqlcipher.SQLiteDatabase getReadableDatabase() {
throw new UnsupportedOperationException("Call getSignalReadableDatabase() instead!");
@@ -2099,7 +2155,7 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper implements SignalDatab
@Override
public net.zetetic.database.sqlcipher.SQLiteDatabase getWritableDatabase() {
throw new UnsupportedOperationException("Call getSignalReadableDatabase() instead!");
throw new UnsupportedOperationException("Call getSignalWritableDatabase() instead!");
}
public net.zetetic.database.sqlcipher.SQLiteDatabase getRawReadableDatabase() {

View File

@@ -128,6 +128,13 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return partCount;
}
public @NonNull MediaMmsMessageRecord withReactions(@NonNull List<ReactionRecord> reactions) {
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getPartCount(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp());
}
public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List<DatabaseAttachment> attachments) {
Map<AttachmentId, DatabaseAttachment> attachmentIdMap = new HashMap<>();
for (DatabaseAttachment attachment : attachments) {

View File

@@ -1,57 +0,0 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Objects;
public class ReactionRecord {
private final String emoji;
private final RecipientId author;
private final long dateSent;
private final long dateReceived;
public ReactionRecord(@NonNull String emoji,
@NonNull RecipientId author,
long dateSent,
long dateReceived)
{
this.emoji = emoji;
this.author = author;
this.dateSent = dateSent;
this.dateReceived = dateReceived;
}
public @NonNull String getEmoji() {
return emoji;
}
public @NonNull RecipientId getAuthor() {
return author;
}
public long getDateSent() {
return dateSent;
}
public long getDateReceived() {
return dateReceived;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ReactionRecord that = (ReactionRecord) o;
return dateSent == that.dateSent &&
dateReceived == that.dateReceived &&
Objects.equals(emoji, that.emoji) &&
Objects.equals(author, that.author);
}
@Override
public int hashCode() {
return Objects.hash(emoji, author, dateSent, dateReceived);
}
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.database.model
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Represents an individual reaction to a message.
*/
data class ReactionRecord(
val emoji: String,
val author: RecipientId,
val dateSent: Long,
val dateReceived: Long
)

View File

@@ -109,4 +109,11 @@ public class SmsMessageRecord extends MessageRecord {
public boolean isMmsNotification() {
return false;
}
public @NonNull SmsMessageRecord withReactions(@NonNull List<ReactionRecord> reactions) {
return new SmsMessageRecord(getId(), getBody(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(),
getServerTimestamp(), getDeliveryReceiptCount(), getType(), getThreadId(), getDeliveryStatus(), getIdentityKeyMismatches(),
getSubscriptionId(), getExpiresIn(), getExpireStarted(), getReadReceiptCount(), isUnidentified(), reactions, isRemoteDelete(),
getNotifiedTimestamp(), getReceiptTimestamp());
}
}

View File

@@ -9,6 +9,7 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.ReactionDatabase;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
@@ -50,8 +51,7 @@ public class ReactionSendJob extends BaseJob {
private static final String KEY_RECIPIENTS = "recipients";
private static final String KEY_INITIAL_RECIPIENT_COUNT = "initial_recipient_count";
private final long messageId;
private final boolean isMms;
private final MessageId messageId;
private final List<RecipientId> recipients;
private final int initialRecipientCount;
private final ReactionRecord reaction;
@@ -60,14 +60,13 @@ public class ReactionSendJob extends BaseJob {
@WorkerThread
public static @NonNull ReactionSendJob create(@NonNull Context context,
long messageId,
boolean isMms,
@NonNull MessageId messageId,
@NonNull ReactionRecord reaction,
boolean remove)
throws NoSuchMessageException
{
MessageRecord message = isMms ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId)
: DatabaseFactory.getSmsDatabase(context).getSmsMessage(messageId);
MessageRecord message = messageId.isMms() ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId.getId())
: DatabaseFactory.getSmsDatabase(context).getSmsMessage(messageId.getId());
Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId());
@@ -84,7 +83,6 @@ public class ReactionSendJob extends BaseJob {
: Collections.singletonList(conversationRecipient.getId());
return new ReactionSendJob(messageId,
isMms,
recipients,
recipients.size(),
reaction,
@@ -96,8 +94,7 @@ public class ReactionSendJob extends BaseJob {
.build());
}
private ReactionSendJob(long messageId,
boolean isMms,
private ReactionSendJob(@NonNull MessageId messageId,
@NonNull List<RecipientId> recipients,
int initialRecipientCount,
@NonNull ReactionRecord reaction,
@@ -107,7 +104,6 @@ public class ReactionSendJob extends BaseJob {
super(parameters);
this.messageId = messageId;
this.isMms = isMms;
this.recipients = recipients;
this.initialRecipientCount = initialRecipientCount;
this.reaction = reaction;
@@ -116,8 +112,8 @@ public class ReactionSendJob extends BaseJob {
@Override
public @NonNull Data serialize() {
return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId)
.putBoolean(KEY_IS_MMS, isMms)
return new Data.Builder().putLong(KEY_MESSAGE_ID, messageId.getId())
.putBoolean(KEY_IS_MMS, messageId.isMms())
.putString(KEY_REACTION_EMOJI, reaction.getEmoji())
.putString(KEY_REACTION_AUTHOR, reaction.getAuthor().serialize())
.putLong(KEY_REACTION_DATE_SENT, reaction.getDateSent())
@@ -139,26 +135,25 @@ public class ReactionSendJob extends BaseJob {
throw new NotPushRegisteredException();
}
MessageDatabase db;
MessageRecord message;
ReactionDatabase reactionDatabase = DatabaseFactory.getReactionDatabase(context);
if (isMms) {
db = DatabaseFactory.getMmsDatabase(context);
message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId);
MessageRecord message;
if (messageId.isMms()) {
message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId.getId());
} else {
db = DatabaseFactory.getSmsDatabase(context);
message = DatabaseFactory.getSmsDatabase(context).getSmsMessage(messageId);
message = DatabaseFactory.getSmsDatabase(context).getSmsMessage(messageId.getId());
}
Recipient targetAuthor = message.isOutgoing() ? Recipient.self() : message.getIndividualRecipient();
long targetSentTimestamp = message.getDateSent();
if (!remove && !db.hasReaction(messageId, reaction)) {
if (!remove && !reactionDatabase.hasReaction(messageId, reaction)) {
Log.w(TAG, "Went to add a reaction, but it's no longer present on the message!");
return;
}
if (remove && db.hasReaction(messageId, reaction)) {
if (remove && reactionDatabase.hasReaction(messageId, reaction)) {
Log.w(TAG, "Went to remove a reaction, but it's still there!");
return;
}
@@ -207,14 +202,14 @@ public class ReactionSendJob extends BaseJob {
Log.w(TAG, "Failed to send the reaction to all recipients!");
MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
ReactionDatabase reactionDatabase = DatabaseFactory.getReactionDatabase(context);
if (remove && !db.hasReaction(messageId, reaction)) {
if (remove && !reactionDatabase.hasReaction(messageId, reaction)) {
Log.w(TAG, "Reaction removal failed, so adding the reaction back.");
db.addReaction(messageId, reaction);
} else if (!remove && db.hasReaction(messageId, reaction)){
reactionDatabase.addReaction(messageId, reaction);
} else if (!remove && reactionDatabase.hasReaction(messageId, reaction)){
Log.w(TAG, "Reaction addition failed, so removing the reaction.");
db.deleteReaction(messageId, reaction.getAuthor());
reactionDatabase.deleteReaction(messageId, reaction.getAuthor());
} else {
Log.w(TAG, "Reaction state didn't match what we'd expect to revert it, so we're just leaving it alone.");
}
@@ -237,7 +232,7 @@ public class ReactionSendJob extends BaseJob {
destinations,
false,
ContentHint.RESENDABLE,
new MessageId(messageId, isMms),
messageId,
dataMessage);
return GroupSendJobHelper.getCompletedSends(destinations, results);
@@ -271,7 +266,7 @@ public class ReactionSendJob extends BaseJob {
data.getLong(KEY_REACTION_DATE_RECEIVED));
boolean remove = data.getBoolean(KEY_REMOVE);
return new ReactionSendJob(messageId, isMms, recipients, initialRecipientCount, reaction, remove, parameters);
return new ReactionSendJob(new MessageId(messageId, isMms), recipients, initialRecipientCount, reaction, remove, parameters);
}
}
}

View File

@@ -874,14 +874,14 @@ public final class MessageContentProcessor {
return null;
}
MessageDatabase db = targetMessage.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
MessageId targetMessageId = new MessageId(targetMessage.getId(), targetMessage.isMms());
if (reaction.isRemove()) {
db.deleteReaction(targetMessage.getId(), senderRecipient.getId());
DatabaseFactory.getReactionDatabase(context).deleteReaction(targetMessageId, senderRecipient.getId());
ApplicationDependencies.getMessageNotifier().updateNotification(context);
} else {
ReactionRecord reactionRecord = new ReactionRecord(reaction.getEmoji(), senderRecipient.getId(), message.getTimestamp(), System.currentTimeMillis());
db.addReaction(targetMessage.getId(), reactionRecord);
DatabaseFactory.getReactionDatabase(context).addReaction(targetMessageId, reactionRecord);
ApplicationDependencies.getMessageNotifier().updateNotification(context, targetMessage.getThreadId(), false);
}

View File

@@ -89,9 +89,10 @@ public class ApplicationMigrations {
static final int CHANGE_NUMBER_CAPABILITY = 45;
static final int CHANGE_NUMBER_CAPABILITY_2 = 46;
static final int DEFAULT_REACTIONS_SYNC = 47;
static final int DB_REACTIONS_MIGRATION = 48;
}
public static final int CURRENT_VERSION = 47;
public static final int CURRENT_VERSION = 48;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -389,6 +390,10 @@ public class ApplicationMigrations {
jobs.put(Version.DEFAULT_REACTIONS_SYNC, new StorageServiceMigrationJob());
}
if (lastSeenVersion < Version.DB_REACTIONS_MIGRATION) {
jobs.put(Version.DB_REACTIONS_MIGRATION, new DatabaseMigrationJob());
}
return jobs;
}

View File

@@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.MmsSmsColumns
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.recipients.Recipient
@@ -34,13 +35,16 @@ object NotificationStateProvider {
while (record != null) {
val threadRecipient: Recipient? = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(record.threadId)
if (threadRecipient != null) {
val hasUnreadReactions = CursorUtil.requireInt(unreadMessages, MmsSmsColumns.REACTIONS_UNREAD) == 1
messages += NotificationMessage(
messageRecord = record,
reactions = if (hasUnreadReactions) DatabaseFactory.getReactionDatabase(context).getReactions(MessageId(record.id, record.isMms)) else emptyList(),
threadRecipient = threadRecipient,
threadId = record.threadId,
stickyThread = stickyThreads.containsKey(record.threadId),
isUnreadMessage = CursorUtil.requireInt(unreadMessages, MmsSmsColumns.READ) == 0,
hasUnreadReactions = CursorUtil.requireInt(unreadMessages, MmsSmsColumns.REACTIONS_UNREAD) == 1,
hasUnreadReactions = hasUnreadReactions,
lastReactionRead = CursorUtil.requireLong(unreadMessages, MmsSmsColumns.REACTIONS_LAST_SEEN)
)
}
@@ -66,7 +70,7 @@ object NotificationStateProvider {
}
if (notification.hasUnreadReactions) {
notification.messageRecord.reactions.filter { notification.includeReaction(it) }
notification.reactions.filter { notification.includeReaction(it) }
.forEach { notificationItems.add(ReactionNotification(notification.threadRecipient, notification.messageRecord, it)) }
}
}
@@ -87,6 +91,7 @@ object NotificationStateProvider {
private data class NotificationMessage(
val messageRecord: MessageRecord,
val reactions: List<ReactionRecord>,
val threadRecipient: Recipient,
val threadId: Long,
val stickyThread: Boolean,

View File

@@ -1,35 +0,0 @@
package org.thoughtcrime.securesms.reactions;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.Recipient;
public class ReactionDetails {
private final Recipient sender;
private final String baseEmoji;
private final String displayEmoji;
private final long timestamp;
ReactionDetails(@NonNull Recipient sender, @NonNull String baseEmoji, @NonNull String displayEmoji, long timestamp) {
this.sender = sender;
this.baseEmoji = baseEmoji;
this.displayEmoji = displayEmoji;
this.timestamp = timestamp;
}
public @NonNull Recipient getSender() {
return sender;
}
public @NonNull String getBaseEmoji() {
return baseEmoji;
}
public @NonNull String getDisplayEmoji() {
return displayEmoji;
}
public long getTimestamp() {
return timestamp;
}
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.reactions
import org.thoughtcrime.securesms.recipients.Recipient
/**
* A UI model for a reaction in the [ReactionsBottomSheetDialogFragment]
*/
data class ReactionDetails(
val sender: Recipient,
val baseEmoji: String,
val displayEmoji: String,
val timestamp: Long
)

View File

@@ -13,8 +13,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.loader.app.LoaderManager;
import androidx.lifecycle.ViewModelProvider;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
@@ -23,6 +22,8 @@ import com.google.android.material.tabs.TabLayoutMediator;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -33,13 +34,13 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
private static final String ARGS_MESSAGE_ID = "reactions.args.message.id";
private static final String ARGS_IS_MMS = "reactions.args.is.mms";
private long messageId;
private ViewPager2 recipientPagerView;
private ReactionsLoader reactionsLoader;
private ReactionViewPagerAdapter recipientsAdapter;
private ReactionsViewModel viewModel;
private Callback callback;
private final LifecycleDisposable disposables = new LifecycleDisposable();
public static DialogFragment create(long messageId, boolean isMms) {
Bundle args = new Bundle();
DialogFragment fragment = new ReactionsBottomSheetDialogFragment();
@@ -61,7 +62,6 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
if (ThemeUtil.isDarkTheme(requireContext())) {
setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_BottomSheetDialog_Fixed_ReactWithAny);
} else {
@@ -82,24 +82,14 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
recipientPagerView = view.findViewById(R.id.reactions_bottom_view_recipient_pager);
messageId = requireArguments().getLong(ARGS_MESSAGE_ID);
disposables.bindTo(getViewLifecycleOwner());
setUpRecipientsRecyclerView();
setUpTabMediator(savedInstanceState);
reactionsLoader = new ReactionsLoader(requireContext(),
requireArguments().getLong(ARGS_MESSAGE_ID),
requireArguments().getBoolean(ARGS_IS_MMS));
LoaderManager.getInstance(requireActivity()).initLoader((int) messageId, null, reactionsLoader);
setUpViewModel();
}
@Override
public void onDestroyView() {
LoaderManager.getInstance(requireActivity()).destroyLoader((int) messageId);
super.onDestroyView();
MessageId messageId = new MessageId(requireArguments().getLong(ARGS_MESSAGE_ID), requireArguments().getBoolean(ARGS_IS_MMS));
setUpViewModel(messageId);
}
@Override
@@ -167,16 +157,16 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
recipientPagerView.setAdapter(recipientsAdapter);
}
private void setUpViewModel() {
ReactionsViewModel.Factory factory = new ReactionsViewModel.Factory(reactionsLoader);
private void setUpViewModel(@NonNull MessageId messageId) {
ReactionsViewModel.Factory factory = new ReactionsViewModel.Factory(messageId);
viewModel = ViewModelProviders.of(this, factory).get(ReactionsViewModel.class);
viewModel = new ViewModelProvider(this, factory).get(ReactionsViewModel.class);
viewModel.getEmojiCounts().observe(getViewLifecycleOwner(), emojiCounts -> {
disposables.add(viewModel.getEmojiCounts().subscribe(emojiCounts -> {
if (emojiCounts.size() <= 1) dismiss();
recipientsAdapter.submitList(emojiCounts);
});
}));
}
public interface Callback {

View File

@@ -1,110 +0,0 @@
package org.thoughtcrime.securesms.reactions;
import android.content.Context;
import android.database.Cursor;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import com.annimon.stream.Stream;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.AbstractCursorLoader;
import java.util.Collections;
import java.util.List;
public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderManager.LoaderCallbacks<Cursor> {
private final long messageId;
private final boolean isMms;
private final Context appContext;
private MutableLiveData<List<ReactionDetails>> internalLiveData = new MutableLiveData<>();
public ReactionsLoader(@NonNull Context context, long messageId, boolean isMms)
{
this.messageId = messageId;
this.isMms = isMms;
this.appContext = context.getApplicationContext();
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, @Nullable Bundle args) {
return isMms ? new MmsMessageRecordCursorLoader(appContext, messageId)
: new SmsMessageRecordCursorLoader(appContext, messageId);
}
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
SignalExecutors.BOUNDED.execute(() -> {
data.moveToPosition(-1);
MessageRecord record = isMms ? MmsDatabase.readerFor(data).getNext()
: SmsDatabase.readerFor(data).getNext();
if (record == null) {
internalLiveData.postValue(Collections.emptyList());
} else {
internalLiveData.postValue(Stream.of(record.getReactions())
.map(reactionRecord -> new ReactionDetails(Recipient.resolved(reactionRecord.getAuthor()),
EmojiUtil.getCanonicalRepresentation(reactionRecord.getEmoji()),
reactionRecord.getEmoji(),
reactionRecord.getDateReceived()))
.toList());
}
});
}
@Override
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
// Do nothing?
}
@Override
public LiveData<List<ReactionDetails>> getReactions() {
return internalLiveData;
}
private static final class MmsMessageRecordCursorLoader extends AbstractCursorLoader {
private final long messageId;
public MmsMessageRecordCursorLoader(@NonNull Context context, long messageId) {
super(context);
this.messageId = messageId;
}
@Override
public Cursor getCursor() {
return DatabaseFactory.getMmsDatabase(context).getMessageCursor(messageId);
}
}
private static final class SmsMessageRecordCursorLoader extends AbstractCursorLoader {
private final long messageId;
public SmsMessageRecordCursorLoader(@NonNull Context context, long messageId) {
super(context);
this.messageId = messageId;
}
@Override
public Cursor getCursor() {
return DatabaseFactory.getSmsDatabase(context).getMessageCursor(messageId);
}
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.reactions
import android.content.Context
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.ObservableEmitter
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
class ReactionsRepository {
fun getReactions(messageId: MessageId): Observable<List<ReactionDetails>> {
return Observable.create { emitter: ObservableEmitter<List<ReactionDetails>> ->
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val messageObserver = DatabaseObserver.MessageObserver { messageId ->
emitter.onNext(fetchReactionDetails(messageId))
}
databaseObserver.registerMessageUpdateObserver(messageObserver)
emitter.setCancellable {
databaseObserver.unregisterObserver(messageObserver)
}
emitter.onNext(fetchReactionDetails(messageId))
}.subscribeOn(Schedulers.io())
}
private fun fetchReactionDetails(messageId: MessageId): List<ReactionDetails> {
val context: Context = ApplicationDependencies.getApplication()
val reactions: List<ReactionRecord> = DatabaseFactory.getReactionDatabase(context).getReactions(messageId)
return reactions.map { reaction ->
ReactionDetails(
sender = Recipient.resolved(reaction.author),
baseEmoji = EmojiUtil.getCanonicalRepresentation(reaction.emoji),
displayEmoji = reaction.emoji,
timestamp = reaction.dateReceived
)
}
}
}

View File

@@ -8,32 +8,45 @@ import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.subjects.PublishSubject;
public class ReactionsViewModel extends ViewModel {
private final Repository repository;
private final MessageId messageId;
private final ReactionsRepository repository;
public ReactionsViewModel(@NonNull Repository repository) {
this.repository = repository;
public ReactionsViewModel(@NonNull MessageId messageId) {
this.messageId = messageId;
this.repository = new ReactionsRepository();
}
public @NonNull LiveData<List<EmojiCount>> getEmojiCounts() {
return Transformations.map(repository.getReactions(),
reactionList -> {
List<EmojiCount> emojiCounts = Stream.of(reactionList)
.groupBy(ReactionDetails::getBaseEmoji)
.sorted(this::compareReactions)
.map(entry -> new EmojiCount(entry.getKey(),
getCountDisplayEmoji(entry.getValue()),
entry.getValue()))
.toList();
public @NonNull Observable<List<EmojiCount>> getEmojiCounts() {
return repository.getReactions(messageId)
.map(reactionList -> {
List<EmojiCount> emojiCounts = Stream.of(reactionList)
.groupBy(ReactionDetails::getBaseEmoji)
.sorted(this::compareReactions)
.map(entry -> new EmojiCount(entry.getKey(),
getCountDisplayEmoji(entry.getValue()),
entry.getValue()))
.toList();
emojiCounts.add(0, EmojiCount.all(reactionList));
emojiCounts.add(0, EmojiCount.all(reactionList));
return emojiCounts;
});
return emojiCounts;
})
.observeOn(AndroidSchedulers.mainThread());
}
private int compareReactions(@NonNull Map.Entry<String, List<ReactionDetails>> lhs, @NonNull Map.Entry<String, List<ReactionDetails>> rhs) {
@@ -48,7 +61,7 @@ public class ReactionsViewModel extends ViewModel {
private long getLatestTimestamp(List<ReactionDetails> reactions) {
return Stream.of(reactions)
.max((a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp()))
.max(Comparator.comparingLong(ReactionDetails::getTimestamp))
.map(ReactionDetails::getTimestamp)
.orElse(-1L);
}
@@ -63,21 +76,17 @@ public class ReactionsViewModel extends ViewModel {
return reactions.get(reactions.size() - 1).getDisplayEmoji();
}
interface Repository {
LiveData<List<ReactionDetails>> getReactions();
}
static final class Factory implements ViewModelProvider.Factory {
private final Repository repository;
private final MessageId messageId;
Factory(@NonNull Repository repository) {
this.repository = repository;
Factory(@NonNull MessageId messageId) {
this.messageId = messageId;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return (T) new ReactionsViewModel(repository);
return modelClass.cast(new ReactionsViewModel(messageId));
}
}
}

View File

@@ -39,8 +39,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageCategoriesAdapter;
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageCategoryMappingModel;
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView;
import org.thoughtcrime.securesms.reactions.ReactionsLoader;
import org.thoughtcrime.securesms.reactions.edit.EditReactionsActivity;
import org.thoughtcrime.securesms.util.LifecycleDisposable;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -65,11 +65,12 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
private ReactWithAnyEmojiViewModel viewModel;
private Callback callback;
private ReactionsLoader reactionsLoader;
private EmojiPageView emojiPageView;
private KeyboardPageSearchView search;
private View tabBar;
private final LifecycleDisposable disposables = new LifecycleDisposable();
private final UpdateCategorySelectionOnScroll categoryUpdateOnScroll = new UpdateCategorySelectionOnScroll();
public static DialogFragment createForMessageRecord(@NonNull MessageRecord messageRecord, int startingPage) {
@@ -174,11 +175,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
reactionsLoader = new ReactionsLoader(requireContext(),
requireArguments().getLong(ARG_MESSAGE_ID),
requireArguments().getBoolean(ARG_IS_MMS));
LoaderManager.getInstance(requireActivity()).initLoader((int) requireArguments().getLong(ARG_MESSAGE_ID), null, reactionsLoader);
disposables.bindTo(getViewLifecycleOwner());
emojiPageView = view.findViewById(R.id.react_with_any_emoji_page_view);
emojiPageView.initialize(this, this, true);
@@ -219,15 +216,15 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
emojiPageView.addOnScrollListener(new TopAndBottomShadowHelper(requireView().findViewById(R.id.react_with_any_emoji_top_shadow),
tabBar.findViewById(R.id.react_with_any_emoji_bottom_shadow)));
viewModel.getEmojiList().observe(getViewLifecycleOwner(), pages -> emojiPageView.setList(pages, null));
viewModel.getCategories().observe(getViewLifecycleOwner(), categoriesAdapter::submitList);
viewModel.getSelectedKey().observe(getViewLifecycleOwner(), key -> categoriesRecycler.post(() -> {
disposables.add(viewModel.getEmojiList().subscribe(pages -> emojiPageView.setList(pages, null)));
disposables.add(viewModel.getCategories().subscribe(categoriesAdapter::submitList));
disposables.add(viewModel.getSelectedKey().subscribe(key -> categoriesRecycler.post(() -> {
int index = categoriesAdapter.indexOfFirst(EmojiKeyboardPageCategoryMappingModel.class, m -> m.getKey().equals(key));
if (index != -1) {
categoriesRecycler.smoothScrollToPosition(index);
}
}));
})));
}
}
@@ -259,7 +256,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
private void initializeViewModel() {
Bundle args = requireArguments();
ReactWithAnyEmojiRepository repository = new ReactWithAnyEmojiRepository(requireContext(), args.getString(ARG_RECENT_KEY, REACTION_STORAGE_KEY));
ReactWithAnyEmojiViewModel.Factory factory = new ReactWithAnyEmojiViewModel.Factory(reactionsLoader, repository, args.getLong(ARG_MESSAGE_ID), args.getBoolean(ARG_IS_MMS));
ReactWithAnyEmojiViewModel.Factory factory = new ReactWithAnyEmojiViewModel.Factory(repository, args.getLong(ARG_MESSAGE_ID), args.getBoolean(ARG_IS_MMS));
viewModel = ViewModelProviders.of(this, factory).get(ReactWithAnyEmojiViewModel.class);
}

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.emoji.EmojiCategory;
@@ -68,24 +69,18 @@ final class ReactWithAnyEmojiRepository {
return pages;
}
void addEmojiToMessage(@NonNull String emoji, long messageId, boolean isMms) {
void addEmojiToMessage(@NonNull String emoji, @NonNull MessageId messageId) {
SignalExecutors.BOUNDED.execute(() -> {
try {
MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
MessageRecord messageRecord = db.getMessageRecord(messageId);
ReactionRecord oldRecord = Stream.of(messageRecord.getReactions())
.filter(record -> record.getAuthor().equals(Recipient.self().getId()))
.findFirst()
.orElse(null);
ReactionRecord oldRecord = Stream.of(DatabaseFactory.getReactionDatabase(context).getReactions(messageId))
.filter(record -> record.getAuthor().equals(Recipient.self().getId()))
.findFirst()
.orElse(null);
if (oldRecord != null && oldRecord.getEmoji().equals(emoji)) {
MessageSender.sendReactionRemoval(context, messageRecord.getId(), messageRecord.isMms(), oldRecord);
} else {
MessageSender.sendNewReaction(context, messageRecord.getId(), messageRecord.isMms(), emoji);
ThreadUtil.runOnMain(() -> recentEmojiPageModel.onCodePointSelected(emoji));
}
} catch (NoSuchMessageException e) {
Log.w(TAG, "Message not found! Ignoring.");
if (oldRecord != null && oldRecord.getEmoji().equals(emoji)) {
MessageSender.sendReactionRemoval(context, messageId, oldRecord);
} else {
MessageSender.sendNewReaction(context, messageId, emoji);
ThreadUtil.runOnMain(() -> recentEmojiPageModel.onCodePointSelected(emoji));
}
});
}

View File

@@ -4,28 +4,29 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter;
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.emoji.EmojiCategory;
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageCategoryMappingModel;
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchRepository;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.reactions.ReactionsLoader;
import org.thoughtcrime.securesms.reactions.ReactionsRepository;
import org.thoughtcrime.securesms.util.MappingModelList;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.List;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.subjects.BehaviorSubject;
public final class ReactWithAnyEmojiViewModel extends ViewModel {
private static final int SEARCH_LIMIT = 40;
@@ -35,13 +36,12 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel {
private final boolean isMms;
private final EmojiSearchRepository emojiSearchRepository;
private final LiveData<MappingModelList> categories;
private final LiveData<MappingModelList> emojiList;
private final MutableLiveData<EmojiSearchResult> searchResults;
private final MutableLiveData<String> selectedKey;
private final Observable<MappingModelList> categories;
private final Observable<MappingModelList> emojiList;
private final BehaviorSubject<EmojiSearchResult> searchResults;
private final BehaviorSubject<String> selectedKey;
private ReactWithAnyEmojiViewModel(@NonNull ReactionsLoader reactionsLoader,
@NonNull ReactWithAnyEmojiRepository repository,
private ReactWithAnyEmojiViewModel(@NonNull ReactWithAnyEmojiRepository repository,
long messageId,
boolean isMms,
@NonNull EmojiSearchRepository emojiSearchRepository)
@@ -50,12 +50,13 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel {
this.messageId = messageId;
this.isMms = isMms;
this.emojiSearchRepository = emojiSearchRepository;
this.searchResults = new MutableLiveData<>(new EmojiSearchResult());
this.selectedKey = new MutableLiveData<>(getStartingKey());
this.searchResults = BehaviorSubject.createDefault(new EmojiSearchResult());
this.selectedKey = BehaviorSubject.createDefault(getStartingKey());
LiveData<List<ReactWithAnyEmojiPage>> emojiPages = Transformations.map(reactionsLoader.getReactions(), repository::getEmojiPageModels);
Observable<List<ReactWithAnyEmojiPage>> emojiPages = new ReactionsRepository().getReactions(new MessageId(messageId, isMms))
.map(repository::getEmojiPageModels);
LiveData<MappingModelList> emojiList = Transformations.map(emojiPages, (pages) -> {
Observable<MappingModelList> emojiList = emojiPages.map(pages -> {
MappingModelList list = new MappingModelList();
for (ReactWithAnyEmojiPage page : pages) {
@@ -69,7 +70,7 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel {
return list;
});
this.categories = LiveDataUtil.combineLatest(emojiPages, this.selectedKey, (pages, selectedKey) -> {
this.categories = Observable.combineLatest(emojiPages, this.selectedKey.distinctUntilChanged(), (pages, selectedKey) -> {
MappingModelList list = new MappingModelList();
list.add(new EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel(RecentEmojiPageModel.KEY.equals(selectedKey)));
list.addAll(pages.stream()
@@ -82,7 +83,7 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel {
return list;
});
this.emojiList = LiveDataUtil.combineLatest(emojiList, searchResults, (all, search) -> {
this.emojiList = Observable.combineLatest(emojiList, searchResults.distinctUntilChanged(), (all, search) -> {
if (TextUtils.isEmpty(search.query)) {
return all;
} else {
@@ -94,35 +95,31 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel {
});
}
LiveData<MappingModelList> getCategories() {
return categories;
@NonNull Observable<MappingModelList> getCategories() {
return categories.observeOn(AndroidSchedulers.mainThread());
}
LiveData<String> getSelectedKey() {
return selectedKey;
@NonNull Observable<String> getSelectedKey() {
return selectedKey.observeOn(AndroidSchedulers.mainThread());
}
@NonNull Observable<MappingModelList> getEmojiList() {
return emojiList.observeOn(AndroidSchedulers.mainThread());
}
void onEmojiSelected(@NonNull String emoji) {
if (messageId > 0) {
SignalStore.emojiValues().setPreferredVariation(emoji);
repository.addEmojiToMessage(emoji, messageId, isMms);
repository.addEmojiToMessage(emoji, new MessageId(messageId, isMms));
}
}
LiveData<MappingModelList> getEmojiList() {
return emojiList;
}
public void onQueryChanged(String query) {
emojiSearchRepository.submitQuery(query, false, SEARCH_LIMIT, m -> searchResults.postValue(new EmojiSearchResult(query, m)));
emojiSearchRepository.submitQuery(query, false, SEARCH_LIMIT, m -> searchResults.onNext(new EmojiSearchResult(query, m)));
}
public void selectPage(@NonNull String key) {
if (key.equals(selectedKey.getValue())) {
return;
}
selectedKey.setValue(key);
selectedKey.onNext(key);
}
private static @NonNull MappingModelList toMappingModels(@NonNull EmojiPageModel model) {
@@ -156,22 +153,20 @@ public final class ReactWithAnyEmojiViewModel extends ViewModel {
static class Factory implements ViewModelProvider.Factory {
private final ReactionsLoader reactionsLoader;
private final ReactWithAnyEmojiRepository repository;
private final long messageId;
private final boolean isMms;
Factory(@NonNull ReactionsLoader reactionsLoader, @NonNull ReactWithAnyEmojiRepository repository, long messageId, boolean isMms) {
this.reactionsLoader = reactionsLoader;
this.repository = repository;
this.messageId = messageId;
this.isMms = isMms;
Factory(@NonNull ReactWithAnyEmojiRepository repository, long messageId, boolean isMms) {
this.repository = repository;
this.messageId = messageId;
this.isMms = isMms;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ReactWithAnyEmojiViewModel(reactionsLoader, repository, messageId, isMms, new EmojiSearchRepository(ApplicationDependencies.getApplication())));
return modelClass.cast(new ReactWithAnyEmojiViewModel(repository, messageId, isMms, new EmojiSearchRepository(ApplicationDependencies.getApplication())));
}
}

View File

@@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
@@ -320,27 +321,23 @@ public class MessageSender {
}
}
public static void sendNewReaction(@NonNull Context context, long messageId, boolean isMms, @NonNull String emoji) {
MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
ReactionRecord reaction = new ReactionRecord(emoji, Recipient.self().getId(), System.currentTimeMillis(), System.currentTimeMillis());
db.addReaction(messageId, reaction);
public static void sendNewReaction(@NonNull Context context, @NonNull MessageId messageId, @NonNull String emoji) {
ReactionRecord reaction = new ReactionRecord(emoji, Recipient.self().getId(), System.currentTimeMillis(), System.currentTimeMillis());
DatabaseFactory.getReactionDatabase(context).addReaction(messageId, reaction);
try {
ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, false));
ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, reaction, false));
onMessageSent();
} catch (NoSuchMessageException e) {
Log.w(TAG, "[sendNewReaction] Could not find message! Ignoring.");
}
}
public static void sendReactionRemoval(@NonNull Context context, long messageId, boolean isMms, @NonNull ReactionRecord reaction) {
MessageDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
db.deleteReaction(messageId, reaction.getAuthor());
public static void sendReactionRemoval(@NonNull Context context, @NonNull MessageId messageId, @NonNull ReactionRecord reaction) {
DatabaseFactory.getReactionDatabase(context).deleteReaction(messageId, reaction.getAuthor());
try {
ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, isMms, reaction, true));
ApplicationDependencies.getJobManager().add(ReactionSendJob.create(context, messageId, reaction, true));
onMessageSent();
} catch (NoSuchMessageException e) {
Log.w(TAG, "[sendReactionRemoval] Could not find message! Ignoring.");

View File

@@ -12,6 +12,10 @@ import io.reactivex.rxjava3.disposables.Disposable
class LifecycleDisposable : DefaultLifecycleObserver {
val disposables: CompositeDisposable = CompositeDisposable()
fun bindTo(lifecycleOwner: LifecycleOwner): LifecycleDisposable {
return bindTo(lifecycleOwner.lifecycle)
}
fun bindTo(lifecycle: Lifecycle): LifecycleDisposable {
lifecycle.addObserver(this)
return this