From 3e3296da5b4cebdc022061651c6864cd6ebb45ac Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 28 Oct 2022 10:57:01 -0400 Subject: [PATCH] Convert ThreadDatabase to kotlin. --- .../database/ThreadDatabaseTest_pinned.kt | 4 +- .../securesms/database/RecipientDatabase.kt | 4 +- .../securesms/database/ThreadDatabase.java | 1950 ----------------- .../securesms/database/ThreadDatabase.kt | 1763 +++++++++++++++ .../tabs/ConversationListTabRepository.kt | 4 +- .../java/org/signal/core/util/CursorUtil.java | 8 - 6 files changed, 1769 insertions(+), 1964 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadDatabaseTest_pinned.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadDatabaseTest_pinned.kt index 7ba8a53d5d..e3f9b8300b 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadDatabaseTest_pinned.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/ThreadDatabaseTest_pinned.kt @@ -36,7 +36,7 @@ class ThreadDatabaseTest_pinned { SignalDatabase.mms.deleteMessage(messageId) // THEN - val pinned = SignalDatabase.threads.pinnedThreadIds + val pinned = SignalDatabase.threads.getPinnedThreadIds() assertTrue(threadId in pinned) } @@ -51,7 +51,7 @@ class ThreadDatabaseTest_pinned { SignalDatabase.mms.deleteMessage(messageId) // THEN - val unarchivedCount = SignalDatabase.threads.unarchivedConversationListCount + val unarchivedCount = SignalDatabase.threads.getUnarchivedConversationListCount() assertEquals(1, unarchivedCount) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt index e0d445fc2d..56b5e9a08c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -3181,7 +3181,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : val recipientsWithinInteractionThreshold: MutableSet = LinkedHashSet() threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1, false)).use { reader -> - var record: ThreadRecord? = reader.next + var record: ThreadRecord? = reader.getNext() while (record != null && record.date > lastInteractionThreshold) { val recipient = Recipient.resolved(record.recipient.id) @@ -3190,7 +3190,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } else { recipientsWithinInteractionThreshold.add(recipient.id) } - record = reader.next + record = reader.getNext() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java deleted file mode 100644 index 1a9512e27d..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ /dev/null @@ -1,1950 +0,0 @@ -/* - * Copyright (C) 2011 Whisper Systems - * Copyright (C) 2013-2017 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.thoughtcrime.securesms.database; - -import android.annotation.SuppressLint; -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.MergeCursor; -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.arch.core.util.Function; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; -import com.fasterxml.jackson.annotation.JsonProperty; - -import org.jsoup.helper.StringUtil; -import org.signal.core.util.CursorUtil; -import org.signal.core.util.SqlUtil; -import org.signal.core.util.logging.Log; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.groups.GroupMasterKey; -import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo; -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.RecipientRecord; -import org.thoughtcrime.securesms.database.model.ThreadRecord; -import org.thoughtcrime.securesms.groups.BadGroupIdException; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.mms.Slide; -import org.thoughtcrime.securesms.mms.SlideDeck; -import org.thoughtcrime.securesms.mms.StickerSlide; -import org.thoughtcrime.securesms.notifications.v2.ConversationId; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientDetails; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.recipients.RecipientUtil; -import org.thoughtcrime.securesms.storage.StorageSyncHelper; -import org.thoughtcrime.securesms.util.ConversationUtil; -import org.thoughtcrime.securesms.util.JsonUtils; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Util; -import org.whispersystems.signalservice.api.push.ServiceId; -import org.whispersystems.signalservice.api.storage.SignalAccountRecord; -import org.whispersystems.signalservice.api.storage.SignalContactRecord; -import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; -import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; -import org.whispersystems.signalservice.api.util.OptionalUtil; - -import java.io.Closeable; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; - -import kotlin.Unit; -import kotlin.collections.CollectionsKt; - -@SuppressLint({ "RecipientIdDatabaseReferenceUsage", "ThreadIdDatabaseReferenceUsage"}) // Handles remapping in a unique way -public class ThreadDatabase extends Database { - - private static final String TAG = Log.tag(ThreadDatabase.class); - - public static final long NO_TRIM_BEFORE_DATE_SET = 0; - public static final int NO_TRIM_MESSAGE_COUNT_SET = Integer.MAX_VALUE; - - public static final String TABLE_NAME = "thread"; - public static final String ID = "_id"; - public static final String DATE = "date"; - public static final String MEANINGFUL_MESSAGES = "message_count"; - public static final String RECIPIENT_ID = "thread_recipient_id"; - public static final String SNIPPET = "snippet"; - private static final String SNIPPET_CHARSET = "snippet_charset"; - public static final String READ = "read"; - public static final String UNREAD_COUNT = "unread_count"; - public static final String TYPE = "type"; - private static final String ERROR = "error"; - public static final String SNIPPET_TYPE = "snippet_type"; - public static final String SNIPPET_URI = "snippet_uri"; - public static final String SNIPPET_CONTENT_TYPE = "snippet_content_type"; - public static final String SNIPPET_EXTRAS = "snippet_extras"; - public static final String ARCHIVED = "archived"; - public static final String STATUS = "status"; - public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; - public static final String READ_RECEIPT_COUNT = "read_receipt_count"; - public static final String EXPIRES_IN = "expires_in"; - public static final String LAST_SEEN = "last_seen"; - public static final String HAS_SENT = "has_sent"; - private static final String LAST_SCROLLED = "last_scrolled"; - static final String PINNED = "pinned"; - private static final String UNREAD_SELF_MENTION_COUNT = "unread_self_mention_count"; - - public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - DATE + " INTEGER DEFAULT 0, " + - MEANINGFUL_MESSAGES + " INTEGER DEFAULT 0, " + - RECIPIENT_ID + " INTEGER, " + - SNIPPET + " TEXT, " + - SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + - READ + " INTEGER DEFAULT " + ReadStatus.READ.serialize() + ", " + - TYPE + " INTEGER DEFAULT 0, " + - ERROR + " INTEGER DEFAULT 0, " + - SNIPPET_TYPE + " INTEGER DEFAULT 0, " + - SNIPPET_URI + " TEXT DEFAULT NULL, " + - SNIPPET_CONTENT_TYPE + " TEXT DEFAULT NULL, " + - SNIPPET_EXTRAS + " TEXT DEFAULT NULL, " + - ARCHIVED + " INTEGER DEFAULT 0, " + - STATUS + " INTEGER DEFAULT 0, " + - DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + - EXPIRES_IN + " INTEGER DEFAULT 0, " + - LAST_SEEN + " INTEGER DEFAULT 0, " + - HAS_SENT + " INTEGER DEFAULT 0, " + - READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + - UNREAD_COUNT + " INTEGER DEFAULT 0, " + - LAST_SCROLLED + " INTEGER DEFAULT 0, " + - PINNED + " INTEGER DEFAULT 0, " + - UNREAD_SELF_MENTION_COUNT + " INTEGER DEFAULT 0);"; - - public static final String[] CREATE_INDEXS = { - "CREATE INDEX IF NOT EXISTS thread_recipient_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");", - "CREATE INDEX IF NOT EXISTS archived_count_index ON " + TABLE_NAME + " (" + ARCHIVED + ", " + MEANINGFUL_MESSAGES + ");", - "CREATE INDEX IF NOT EXISTS thread_pinned_index ON " + TABLE_NAME + " (" + PINNED + ");", - }; - - private static final String[] THREAD_PROJECTION = { - ID, DATE, MEANINGFUL_MESSAGES, RECIPIENT_ID, SNIPPET, SNIPPET_CHARSET, READ, UNREAD_COUNT, TYPE, ERROR, SNIPPET_TYPE, - SNIPPET_URI, SNIPPET_CONTENT_TYPE, SNIPPET_EXTRAS, ARCHIVED, STATUS, DELIVERY_RECEIPT_COUNT, EXPIRES_IN, LAST_SEEN, - READ_RECEIPT_COUNT, LAST_SCROLLED, PINNED, UNREAD_SELF_MENTION_COUNT - }; - - private static final List TYPED_THREAD_PROJECTION = Stream.of(THREAD_PROJECTION) - .map(columnName -> TABLE_NAME + "." + columnName) - .toList(); - - private static final List COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION = Stream.concat(Stream.concat(Stream.of(TYPED_THREAD_PROJECTION), - Stream.of(RecipientDatabase.TYPED_RECIPIENT_PROJECTION_NO_ID)), - Stream.of(GroupDatabase.TYPED_GROUP_PROJECTION)) - .toList(); - - private static final String[] RECIPIENT_ID_PROJECTION = new String[] { RECIPIENT_ID }; - - public ThreadDatabase(Context context, SignalDatabase databaseHelper) { - super(context, databaseHelper); - } - - private long createThreadForRecipient(@NonNull RecipientId recipientId, boolean group, int distributionType) { - if (recipientId.isUnknown()) { - throw new AssertionError("Cannot create a thread for an unknown recipient!"); - } - - ContentValues contentValues = new ContentValues(4); - long date = System.currentTimeMillis(); - - contentValues.put(DATE, date - date % 1000); - contentValues.put(RECIPIENT_ID, recipientId.serialize()); - - if (group) - contentValues.put(TYPE, distributionType); - - contentValues.put(MEANINGFUL_MESSAGES, 0); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - long result = db.insert(TABLE_NAME, null, contentValues); - - Recipient.live(recipientId).refresh(); - - return result; - } - - private void updateThread(long threadId, boolean meaningfulMessages, String body, @Nullable Uri attachment, - @Nullable String contentType, @Nullable Extra extra, - long date, int status, int deliveryReceiptCount, long type, boolean unarchive, - long expiresIn, int readReceiptCount) - { - String extraSerialized = null; - - if (extra != null) { - try { - extraSerialized = JsonUtils.toJson(extra); - } catch (IOException e) { - throw new AssertionError(e); - } - } - - ContentValues contentValues = new ContentValues(); - contentValues.put(DATE, date - date % 1000); - contentValues.put(SNIPPET, body); - contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString()); - contentValues.put(SNIPPET_TYPE, type); - contentValues.put(SNIPPET_CONTENT_TYPE, contentType); - contentValues.put(SNIPPET_EXTRAS, extraSerialized); - contentValues.put(MEANINGFUL_MESSAGES, meaningfulMessages ? 1 : 0); - contentValues.put(STATUS, status); - contentValues.put(DELIVERY_RECEIPT_COUNT, deliveryReceiptCount); - contentValues.put(READ_RECEIPT_COUNT, readReceiptCount); - contentValues.put(EXPIRES_IN, expiresIn); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - - if (unarchive) { - ContentValues archiveValues = new ContentValues(); - archiveValues.put(ARCHIVED, 0); - - SqlUtil.Query query = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(threadId), archiveValues); - if (db.update(TABLE_NAME, archiveValues, query.getWhere(), query.getWhereArgs()) > 0) { - StorageSyncHelper.scheduleSyncForDataChange(); - } - } - } - - public void updateSnippetUriSilently(long threadId, @Nullable Uri attachment) { - ContentValues contentValues = new ContentValues(); - contentValues.put(SNIPPET_URI, attachment != null ? attachment.toString() : null); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - } - - public void updateSnippet(long threadId, String snippet, @Nullable Uri attachment, long date, long type, boolean unarchive) { - if (isSilentType(type)) { - return; - } - - ContentValues contentValues = new ContentValues(); - contentValues.put(DATE, date - date % 1000); - contentValues.put(SNIPPET, snippet); - contentValues.put(SNIPPET_TYPE, type); - contentValues.put(SNIPPET_URI, attachment == null ? null : attachment.toString()); - - if (unarchive) { - contentValues.put(ARCHIVED, 0); - } - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - notifyConversationListListeners(); - } - - public void trimAllThreads(int length, long trimBeforeDate) { - if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { - return; - } - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - AttachmentDatabase attachmentDatabase = SignalDatabase.attachments(); - GroupReceiptDatabase groupReceiptDatabase = SignalDatabase.groupReceipts(); - MmsSmsDatabase mmsSmsDatabase = SignalDatabase.mmsSms(); - MentionDatabase mentionDatabase = SignalDatabase.mentions(); - int deletes = 0; - - try (Cursor cursor = databaseHelper.getSignalReadableDatabase().query(TABLE_NAME, new String[] { ID }, null, null, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - trimThreadInternal(CursorUtil.requireLong(cursor, ID), length, trimBeforeDate); - } - } - - db.beginTransaction(); - - try { - mmsSmsDatabase.deleteAbandonedMessages(); - attachmentDatabase.trimAllAbandonedAttachments(); - groupReceiptDatabase.deleteAbandonedRows(); - mentionDatabase.deleteAbandonedMentions(); - deletes = attachmentDatabase.deleteAbandonedAttachmentFiles(); - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - if (deletes > 0) { - Log.i(TAG, "Trim all threads caused " + deletes + " attachments to be deleted."); - } - - notifyAttachmentListeners(); - notifyStickerPackListeners(); - } - - public void trimThread(long threadId, int length, long trimBeforeDate) { - if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { - return; - } - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - AttachmentDatabase attachmentDatabase = SignalDatabase.attachments(); - GroupReceiptDatabase groupReceiptDatabase = SignalDatabase.groupReceipts(); - MmsSmsDatabase mmsSmsDatabase = SignalDatabase.mmsSms(); - MentionDatabase mentionDatabase = SignalDatabase.mentions(); - int deletes = 0; - - db.beginTransaction(); - - try { - trimThreadInternal(threadId, length, trimBeforeDate); - mmsSmsDatabase.deleteAbandonedMessages(); - attachmentDatabase.trimAllAbandonedAttachments(); - groupReceiptDatabase.deleteAbandonedRows(); - mentionDatabase.deleteAbandonedMentions(); - deletes = attachmentDatabase.deleteAbandonedAttachmentFiles(); - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - if (deletes > 0) { - Log.i(TAG, "Trim thread " + threadId + " caused " + deletes + " attachments to be deleted."); - } - - notifyAttachmentListeners(); - notifyStickerPackListeners(); - } - - private void trimThreadInternal(long threadId, int length, long trimBeforeDate) { - if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { - return; - } - - if (length != NO_TRIM_MESSAGE_COUNT_SET) { - try (Cursor cursor = SignalDatabase.mmsSms().getConversation(threadId)) { - if (cursor != null && length > 0 && cursor.getCount() > length) { - cursor.moveToPosition(length - 1); - trimBeforeDate = Math.max(trimBeforeDate, cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.NORMALIZED_DATE_RECEIVED))); - } - } - } - - if (trimBeforeDate != NO_TRIM_BEFORE_DATE_SET) { - Log.i(TAG, "Trimming thread: " + threadId + " before: " + trimBeforeDate); - - int deletes = SignalDatabase.mmsSms().deleteMessagesInThreadBeforeDate(threadId, trimBeforeDate); - - if (deletes > 0) { - Log.i(TAG, "Trimming deleted " + deletes + " messages thread: " + threadId); - setLastScrolled(threadId, 0); - update(threadId, false); - notifyConversationListeners(threadId); - } else { - Log.i(TAG, "Trimming deleted no messages thread: " + threadId); - } - } - } - - public List setAllThreadsRead() { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - ContentValues contentValues = new ContentValues(1); - contentValues.put(READ, ReadStatus.READ.serialize()); - contentValues.put(UNREAD_COUNT, 0); - contentValues.put(UNREAD_SELF_MENTION_COUNT, 0); - - db.update(TABLE_NAME, contentValues, null, null); - - final List smsRecords = SignalDatabase.sms().setAllMessagesRead(); - final List mmsRecords = SignalDatabase.mms().setAllMessagesRead(); - - SignalDatabase.sms().setAllReactionsSeen(); - SignalDatabase.mms().setAllReactionsSeen(); - - notifyConversationListListeners(); - - return Util.concatenatedList(smsRecords, mmsRecords); - } - - public boolean hasCalledSince(@NonNull Recipient recipient, long timestamp) { - return hasReceivedAnyCallsSince(getOrCreateThreadIdFor(recipient), timestamp); - } - - public boolean hasReceivedAnyCallsSince(long threadId, long timestamp) { - return SignalDatabase.mmsSms().hasReceivedAnyCallsSince(threadId, timestamp); - } - - public List setEntireThreadRead(long threadId) { - setRead(threadId, false); - - final List smsRecords = SignalDatabase.sms().setEntireThreadRead(threadId); - final List mmsRecords = SignalDatabase.mms().setEntireThreadRead(threadId); - - return Util.concatenatedList(smsRecords, mmsRecords); - } - - public List setRead(long threadId, boolean lastSeen) { - return setReadSince(Collections.singletonMap(threadId, -1L), lastSeen); - } - - public List setRead(@NonNull ConversationId conversationId, boolean lastSeen) { - if (conversationId.getGroupStoryId() == null) { - return setRead(conversationId.getThreadId(), lastSeen); - } else { - return setGroupStoryReadSince(conversationId.getThreadId(), conversationId.getGroupStoryId(), System.currentTimeMillis()); - } - } - - public List setReadSince(@NonNull ConversationId conversationId, boolean lastSeen, long sinceTimestamp) { - if (conversationId.getGroupStoryId() != null) { - return setGroupStoryReadSince(conversationId.getThreadId(), conversationId.getGroupStoryId(), sinceTimestamp); - } else { - return setReadSince(conversationId.getThreadId(), lastSeen, sinceTimestamp); - } - } - - public List setReadSince(long threadId, boolean lastSeen, long sinceTimestamp) { - return setReadSince(Collections.singletonMap(threadId, sinceTimestamp), lastSeen); - } - - public List setRead(Collection threadIds, boolean lastSeen) { - return setReadSince(Stream.of(threadIds).collect(Collectors.toMap(t -> t, t -> -1L)), lastSeen); - } - - public List setGroupStoryReadSince(long threadId, long groupStoryId, long sinceTimestamp) { - return SignalDatabase.mms().setGroupStoryMessagesReadSince(threadId, groupStoryId, sinceTimestamp); - } - - public List setReadSince(Map threadIdToSinceTimestamp, boolean lastSeen) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - List smsRecords = new LinkedList<>(); - List mmsRecords = new LinkedList<>(); - boolean needsSync = false; - - db.beginTransaction(); - - try { - ContentValues contentValues = new ContentValues(2); - contentValues.put(READ, ReadStatus.READ.serialize()); - - for (Map.Entry entry : threadIdToSinceTimestamp.entrySet()) { - long threadId = entry.getKey(); - long sinceTimestamp = entry.getValue(); - - if (lastSeen) { - contentValues.put(LAST_SEEN, sinceTimestamp == -1 ? System.currentTimeMillis() : sinceTimestamp); - } - - ThreadRecord previous = getThreadRecord(threadId); - - smsRecords.addAll(SignalDatabase.sms().setMessagesReadSince(threadId, sinceTimestamp)); - mmsRecords.addAll(SignalDatabase.mms().setMessagesReadSince(threadId, sinceTimestamp)); - - SignalDatabase.sms().setReactionsSeen(threadId, sinceTimestamp); - SignalDatabase.mms().setReactionsSeen(threadId, sinceTimestamp); - - int unreadCount = SignalDatabase.mmsSms().getUnreadCount(threadId); - contentValues.put(UNREAD_COUNT, unreadCount); - - int unreadMentionsCount = SignalDatabase.mms().getUnreadMentionCount(threadId); - contentValues.put(UNREAD_SELF_MENTION_COUNT, unreadMentionsCount); - - db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - - if (previous != null && previous.isForcedUnread()) { - SignalDatabase.recipients().markNeedsSync(previous.getRecipient().getId()); - needsSync = true; - } - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - notifyVerboseConversationListeners(threadIdToSinceTimestamp.keySet()); - notifyConversationListListeners(); - - if (needsSync) { - StorageSyncHelper.scheduleSyncForDataChange(); - } - - return Util.concatenatedList(smsRecords, mmsRecords); - } - - public void setForcedUnread(@NonNull Collection threadIds) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - List recipientIds = Collections.emptyList(); - - db.beginTransaction(); - try { - SqlUtil.Query query = SqlUtil.buildSingleCollectionQuery(ID, threadIds); - ContentValues contentValues = new ContentValues(); - - contentValues.put(READ, ReadStatus.FORCED_UNREAD.serialize()); - - db.update(TABLE_NAME, contentValues, query.getWhere(), query.getWhereArgs()); - - recipientIds = getRecipientIdsForThreadIds(threadIds); - SignalDatabase.recipients().markNeedsSyncWithoutRefresh(recipientIds); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - - for (RecipientId id : recipientIds) { - Recipient.live(id).refresh(); - } - - StorageSyncHelper.scheduleSyncForDataChange(); - notifyConversationListListeners(); - } - } - - public @NonNull Long getUnreadThreadCount() { - return getUnreadThreadIdAggregate(SqlUtil.COUNT, cursor -> CursorUtil.getAggregateOrDefault(cursor, 0L, cursor::getLong)); - } - - public long getUnreadMessageCount(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - try (Cursor cursor = db.query(TABLE_NAME, SqlUtil.buildArgs(UNREAD_COUNT), ID_WHERE, SqlUtil.buildArgs(threadId), null, null, null)) { - if (cursor.moveToFirst()) { - return CursorUtil.requireLong(cursor, UNREAD_COUNT); - } else { - return 0L; - } - } - } - - public @Nullable String getUnreadThreadIdList() { - return getUnreadThreadIdAggregate(SqlUtil.buildArgs("GROUP_CONCAT(" + ID + ")"), - cursor -> CursorUtil.getAggregateOrDefault(cursor, null, cursor::getString)); - } - - private @NonNull T getUnreadThreadIdAggregate(@NonNull String[] aggregator, @NonNull Function mapCursorToType) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String where = READ + " != " + ReadStatus.READ.serialize() + " AND " + ARCHIVED + " = 0 AND " + MEANINGFUL_MESSAGES + " != 0"; - - try (Cursor cursor = db.query(TABLE_NAME, aggregator, where, null, null, null, null)) { - return mapCursorToType.apply(cursor); - } - } - - public void incrementUnread(long threadId, int unreadAmount, int unreadSelfMentionAmount) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + - READ + " = " + ReadStatus.UNREAD.serialize() + ", " + - UNREAD_COUNT + " = " + UNREAD_COUNT + " + ?, " + - UNREAD_SELF_MENTION_COUNT + " = " + UNREAD_SELF_MENTION_COUNT + " + ?, " + - LAST_SCROLLED + " = ? " + - "WHERE " + ID + " = ?", - SqlUtil.buildArgs(unreadAmount, unreadSelfMentionAmount, 0, threadId)); - } - - public void setDistributionType(long threadId, int distributionType) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(TYPE, distributionType); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId + ""}); - notifyConversationListListeners(); - } - - public int getDistributionType(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - Cursor cursor = db.query(TABLE_NAME, new String[]{TYPE}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null); - - try { - if (cursor != null && cursor.moveToNext()) { - return cursor.getInt(cursor.getColumnIndexOrThrow(TYPE)); - } - - return DistributionTypes.DEFAULT; - } finally { - if (cursor != null) cursor.close(); - } - - } - - public Cursor getFilteredConversationList(@Nullable List filter) { - if (filter == null || filter.size() == 0) - return null; - - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - List> splitRecipientIds = Util.partition(filter, 900); - List cursors = new LinkedList<>(); - - for (List recipientIds : splitRecipientIds) { - String selection = TABLE_NAME + "." + RECIPIENT_ID + " = ?"; - String[] selectionArgs = new String[recipientIds.size()]; - - for (int i=0;i 1 ? new MergeCursor(cursors.toArray(new Cursor[cursors.size()])) : cursors.get(0); - return cursor; - } - - public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean hideV1Groups) { - return getRecentConversationList(limit, includeInactiveGroups, false, false, hideV1Groups, false, false); - } - - public Cursor getRecentConversationList(int limit, boolean includeInactiveGroups, boolean individualsOnly, boolean groupsOnly, boolean hideV1Groups, boolean hideSms, boolean hideSelf) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = !includeInactiveGroups ? MEANINGFUL_MESSAGES + " != 0 AND (" + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " IS NULL OR " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1)" - : MEANINGFUL_MESSAGES + " != 0"; - - if (groupsOnly) { - query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " NOT NULL"; - } - - if (individualsOnly) { - query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " IS NULL"; - } - - if (hideV1Groups) { - query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_TYPE + " != " + RecipientDatabase.GroupType.SIGNAL_V1.getId(); - } - - if (hideSms) { - query += " AND ((" + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_ID + " NOT NULL AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.GROUP_TYPE + " != " + RecipientDatabase.GroupType.MMS.getId() + ")" + - " OR " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.REGISTERED + " = " + RecipientDatabase.RegisteredState.REGISTERED.getId() + ")"; - query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.FORCE_SMS_SELECTION + " = 0"; - } - - if (hideSelf) { - query += " AND " + RECIPIENT_ID + " != " + Recipient.self().getId().toLong(); - } - - query += " AND " + ARCHIVED + " = 0"; - query += " AND " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.BLOCKED + " = 0"; - - if (SignalStore.releaseChannelValues().getReleaseChannelRecipientId() != null) { - query += " AND " + RECIPIENT_ID + " != " + SignalStore.releaseChannelValues().getReleaseChannelRecipientId().toLong(); - } - - return db.rawQuery(createQuery(query, 0, limit, true), null); - } - - public Cursor getRecentPushConversationList(int limit, boolean includeInactiveGroups) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String activeGroupQuery = !includeInactiveGroups ? " AND " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1" : ""; - String where = MEANINGFUL_MESSAGES + " != 0 AND " + - "(" + - RecipientDatabase.REGISTERED + " = " + RecipientDatabase.RegisteredState.REGISTERED.getId() + " OR " + - "(" + - GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID + " NOT NULL AND " + - GroupDatabase.TABLE_NAME + "." + GroupDatabase.MMS + " = 0" + - activeGroupQuery + - ")" + - ")"; - String query = createQuery(where, 0, limit, true); - - return db.rawQuery(query, null); - } - - public @NonNull List getRecentV1Groups(int limit) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String where = MEANINGFUL_MESSAGES + " != 0 AND " + - "(" + - GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1 AND " + - GroupDatabase.TABLE_NAME + "." + GroupDatabase.V2_MASTER_KEY + " IS NULL AND " + - GroupDatabase.TABLE_NAME + "." + GroupDatabase.MMS + " = 0" + - ")"; - String query = createQuery(where, 0, limit, true); - - List threadRecords = new ArrayList<>(); - - try (Reader reader = readerFor(db.rawQuery(query, null))) { - ThreadRecord record; - - while ((record = reader.getNext()) != null) { - threadRecords.add(record); - } - } - return threadRecords; - } - - public Cursor getArchivedConversationList() { - return getConversationList("1"); - } - - public boolean isArchived(@NonNull RecipientId recipientId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = RECIPIENT_ID + " = ?"; - String[] args = new String[]{ recipientId.serialize() }; - - try (Cursor cursor = db.query(TABLE_NAME, new String[] { ARCHIVED }, query, args, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(cursor.getColumnIndexOrThrow(ARCHIVED)) == 1; - } - } - - return false; - } - - public void setArchived(Set threadIds, boolean archive) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - List recipientIds = Collections.emptyList(); - - db.beginTransaction(); - try { - for (long threadId : threadIds) { - ContentValues values = new ContentValues(2); - - if (archive) { - values.put(PINNED, "0"); - } - - values.put(ARCHIVED, archive ? "1" : "0"); - db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(threadId)); - } - - recipientIds = getRecipientIdsForThreadIds(threadIds); - SignalDatabase.recipients().markNeedsSyncWithoutRefresh(recipientIds); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - - for (RecipientId id : recipientIds) { - Recipient.live(id).refresh(); - } - - notifyConversationListListeners(); - StorageSyncHelper.scheduleSyncForDataChange(); - } - } - - public @NonNull Set getArchivedRecipients() { - Set archived = new HashSet<>(); - - try (Cursor cursor = getArchivedConversationList()) { - while (cursor != null && cursor.moveToNext()) { - archived.add(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID)))); - } - } - - return archived; - } - - public @NonNull Map getInboxPositions() { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = createQuery(MEANINGFUL_MESSAGES + " != ?", 0); - - Map positions = new HashMap<>(); - - try (Cursor cursor = db.rawQuery(query, new String[] { "0" })) { - int i = 0; - while (cursor != null && cursor.moveToNext()) { - RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID))); - positions.put(recipientId, i); - i++; - } - } - - return positions; - } - - public Cursor getArchivedConversationList(long offset, long limit) { - return getConversationList("1", offset, limit); - } - - private Cursor getConversationList(String archived) { - return getConversationList(archived, 0, 0); - } - - public Cursor getUnarchivedConversationList(boolean pinned, long offset, long limit) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String pinnedWhere = PINNED + (pinned ? " != 0" : " = 0"); - String meaningful = pinned ? "" : MEANINGFUL_MESSAGES + " != 0 AND "; - String where = ARCHIVED + " = 0 AND " + meaningful + pinnedWhere; - - final String query; - - if (pinned) { - query = createQuery(where, PINNED + " ASC", offset, limit); - } else { - query = createQuery(where, offset, limit, false); - } - - Cursor cursor = db.rawQuery(query, new String[]{}); - - return cursor; - } - - private Cursor getConversationList(@NonNull String archived, long offset, long limit) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = createQuery(ARCHIVED + " = ? AND " + MEANINGFUL_MESSAGES + " != 0", offset, limit, false); - Cursor cursor = db.rawQuery(query, new String[]{archived}); - - return cursor; - } - - public int getArchivedConversationListCount() { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] columns = new String[] { "COUNT(*)" }; - String query = ARCHIVED + " = ? AND " + MEANINGFUL_MESSAGES + " != 0"; - String[] args = new String[] {"1"}; - - try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - public int getPinnedConversationListCount() { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] columns = new String[] { "COUNT(*)" }; - String query = ARCHIVED + " = 0 AND " + PINNED + " != 0"; - - try (Cursor cursor = db.query(TABLE_NAME, columns, query, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - public int getUnarchivedConversationListCount() { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] columns = new String[] { "COUNT(*)" }; - String query = ARCHIVED + " = 0 AND (" + MEANINGFUL_MESSAGES + " != 0 OR " + PINNED + " != 0)"; - - try (Cursor cursor = db.query(TABLE_NAME, columns, query, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - /** - * @return Pinned recipients, in order from top to bottom. - */ - public @NonNull List getPinnedRecipientIds() { - String[] projection = new String[]{ID, RECIPIENT_ID}; - List pinned = new LinkedList<>(); - - try (Cursor cursor = getPinned(projection)) { - while (cursor.moveToNext()) { - pinned.add(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID))); - } - } - - return pinned; - } - - /** - * @return Pinned thread ids, in order from top to bottom. - */ - public @NonNull List getPinnedThreadIds() { - String[] projection = new String[]{ID}; - List pinned = new LinkedList<>(); - - try (Cursor cursor = getPinned(projection)) { - while (cursor.moveToNext()) { - pinned.add(CursorUtil.requireLong(cursor, ID)); - } - } - - return pinned; - } - - /** - * @return Pinned recipients, in order from top to bottom. - */ - private @NonNull Cursor getPinned(String[] projection) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = PINNED + " > ?"; - String[] args = SqlUtil.buildArgs(0); - - return db.query(TABLE_NAME, projection, query, args, null, null, PINNED + " ASC"); - } - - public void restorePins(@NonNull Collection threadIds) { - Log.d(TAG, "Restoring pinned threads " + StringUtil.join(threadIds, ",")); - pinConversations(threadIds, true); - } - - public void pinConversations(@NonNull Collection threadIds) { - Log.d(TAG, "Pinning threads " + StringUtil.join(threadIds, ",")); - pinConversations(threadIds, false); - } - - private void pinConversations(@NonNull Collection threadIds, boolean clearFirst) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - threadIds = new LinkedHashSet<>(threadIds); - - try { - db.beginTransaction(); - - if (clearFirst) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(PINNED, 0); - String query = PINNED + " > ?"; - String[] args = SqlUtil.buildArgs(0); - db.update(TABLE_NAME, contentValues, query, args); - } - - int pinnedCount = getPinnedConversationListCount(); - - if (pinnedCount > 0 && clearFirst) { - throw new AssertionError(); - } - - for (long threadId : threadIds) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(PINNED, ++pinnedCount); - - db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - notifyConversationListListeners(); - } - - notifyConversationListListeners(); - - SignalDatabase.recipients().markNeedsSync(Recipient.self().getId()); - StorageSyncHelper.scheduleSyncForDataChange(); - } - - public void unpinConversations(@NonNull Collection threadIds) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - ContentValues contentValues = new ContentValues(1); - String placeholders = StringUtil.join(Stream.of(threadIds).map(unused -> "?").toList(), ","); - String selection = ID + " IN (" + placeholders + ")"; - List remainingPinnedThreads = getPinnedThreadIds(); - - remainingPinnedThreads.removeAll(threadIds); - contentValues.put(PINNED, 0); - - db.beginTransaction(); - try { - db.update(TABLE_NAME, contentValues, selection, SqlUtil.buildArgs(Stream.of(threadIds).toArray())); - - CollectionsKt.forEachIndexed(remainingPinnedThreads, (index, threadId) -> { - ContentValues values = new ContentValues(1); - values.put(PINNED, index + 1); - db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(threadId)); - - return Unit.INSTANCE; - }); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - notifyConversationListListeners(); - - SignalDatabase.recipients().markNeedsSync(Recipient.self().getId()); - StorageSyncHelper.scheduleSyncForDataChange(); - } - - public void archiveConversation(long threadId) { - setArchived(Collections.singleton(threadId), true); - } - - public void unarchiveConversation(long threadId) { - setArchived(Collections.singleton(threadId), false); - } - - public void setLastSeen(long threadId) { - setLastSeenSilently(threadId); - notifyConversationListListeners(); - } - - void setLastSeenSilently(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - ContentValues contentValues = new ContentValues(1); - contentValues.put(LAST_SEEN, System.currentTimeMillis()); - - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(threadId)}); - } - - public void setLastScrolled(long threadId, long lastScrolledTimestamp) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - ContentValues contentValues = new ContentValues(1); - - contentValues.put(LAST_SCROLLED, lastScrolledTimestamp); - - db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - } - - public ConversationMetadata getConversationMetadata(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - try (Cursor cursor = db.query(TABLE_NAME, new String[]{LAST_SEEN, HAS_SENT, LAST_SCROLLED}, ID_WHERE, new String[]{String.valueOf(threadId)}, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return new ConversationMetadata(cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SEEN)), - cursor.getLong(cursor.getColumnIndexOrThrow(HAS_SENT)) == 1, - cursor.getLong(cursor.getColumnIndexOrThrow(LAST_SCROLLED))); - } - - return new ConversationMetadata(-1L, false, -1); - } - } - - public void deleteConversation(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - RecipientId recipientIdForThreadId = getRecipientIdForThreadId(threadId); - - db.beginTransaction(); - try { - SignalDatabase.sms().deleteThread(threadId); - SignalDatabase.mms().deleteThread(threadId); - SignalDatabase.drafts().clearDrafts(threadId); - - db.delete(TABLE_NAME, ID_WHERE, new String[]{threadId + ""}); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - notifyConversationListListeners(); - notifyConversationListeners(threadId); - ConversationUtil.clearShortcuts(context, Collections.singleton(recipientIdForThreadId)); - } - - public void deleteConversations(Set selectedConversations) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - List recipientIdsForThreadIds = getRecipientIdsForThreadIds(selectedConversations); - - db.beginTransaction(); - try { - SignalDatabase.sms().deleteThreads(selectedConversations); - SignalDatabase.mms().deleteThreads(selectedConversations); - SignalDatabase.drafts().clearDrafts(selectedConversations); - - StringBuilder where = new StringBuilder(); - - for (long threadId : selectedConversations) { - if (where.length() > 0) { - where.append(" OR "); - } - where.append(ID + " = '").append(threadId).append("'"); - } - - db.delete(TABLE_NAME, where.toString(), null); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - notifyConversationListListeners(); - notifyConversationListeners(selectedConversations); - ConversationUtil.clearShortcuts(context, recipientIdsForThreadIds); - } - - public void deleteAllConversations() { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - db.beginTransaction(); - try { - SignalDatabase.messageLog().deleteAll(); - SignalDatabase.sms().deleteAllThreads(); - SignalDatabase.mms().deleteAllThreads(); - SignalDatabase.drafts().clearAllDrafts(); - - db.delete(TABLE_NAME, null, null); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - notifyConversationListListeners(); - ConversationUtil.clearAllShortcuts(context); - } - - public long getThreadIdIfExistsFor(@NonNull RecipientId recipientId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String where = RECIPIENT_ID + " = ?"; - String[] recipientsArg = new String[] {recipientId.serialize()}; - - try (Cursor cursor = db.query(TABLE_NAME, new String[]{ ID }, where, recipientsArg, null, null, null, "1")) { - if (cursor != null && cursor.moveToFirst()) { - return CursorUtil.requireLong(cursor, ID); - } else { - return -1; - } - } - } - - public Map getThreadIdsIfExistsFor(@NonNull RecipientId ... recipientIds) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - SqlUtil.Query query = SqlUtil.buildSingleCollectionQuery(RECIPIENT_ID, Arrays.asList(recipientIds)); - - Map results = new HashMap<>(); - try (Cursor cursor = db.query(TABLE_NAME, new String[]{ ID, RECIPIENT_ID }, query.getWhere(), query.getWhereArgs(), null, null, null, "1")) { - while (cursor != null && cursor.moveToNext()) { - results.put(RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)), CursorUtil.requireLong(cursor, ID)); - } - } - return results; - } - - public long getOrCreateValidThreadId(@NonNull Recipient recipient, long candidateId) { - return getOrCreateValidThreadId(recipient, candidateId, DistributionTypes.DEFAULT); - } - - public long getOrCreateValidThreadId(@NonNull Recipient recipient, long candidateId, int distributionType) { - if (candidateId != -1) { - Optional remapped = RemappedRecords.getInstance().getThread(candidateId); - - if (remapped.isPresent()) { - Log.i(TAG, "Using remapped threadId: " + candidateId + " -> " + remapped.get()); - return remapped.get(); - } else { - return candidateId; - } - } else { - return getOrCreateThreadIdFor(recipient, distributionType); - } - } - - public long getOrCreateThreadIdFor(@NonNull Recipient recipient) { - return getOrCreateThreadIdFor(recipient, DistributionTypes.DEFAULT); - } - - public long getOrCreateThreadIdFor(@NonNull Recipient recipient, int distributionType) { - Long threadId = getThreadIdFor(recipient.getId()); - if (threadId != null) { - return threadId; - } else { - return createThreadForRecipient(recipient.getId(), recipient.isGroup(), distributionType); - } - } - - public @Nullable Long getThreadIdFor(@NonNull RecipientId recipientId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String where = RECIPIENT_ID + " = ?"; - String[] recipientsArg = new String[]{recipientId.serialize()}; - - try (Cursor cursor = db.query(TABLE_NAME, new String[]{ ID }, where, recipientsArg, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getLong(cursor.getColumnIndexOrThrow(ID)); - } else { - return null; - } - } - } - - public @Nullable RecipientId getRecipientIdForThreadId(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - try (Cursor cursor = db.query(TABLE_NAME, RECIPIENT_ID_PROJECTION, ID_WHERE, SqlUtil.buildArgs(threadId), null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))); - } - } - - return null; - } - - public @Nullable Recipient getRecipientForThreadId(long threadId) { - RecipientId id = getRecipientIdForThreadId(threadId); - if (id == null) return null; - return Recipient.resolved(id); - } - - public @NonNull List getRecipientIdsForThreadIds(Collection threadIds) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - SqlUtil.Query query = SqlUtil.buildSingleCollectionQuery(ID, threadIds); - List ids = new ArrayList<>(threadIds.size()); - - try (Cursor cursor = db.query(TABLE_NAME, new String[] { RECIPIENT_ID }, query.getWhere(), query.getWhereArgs(), null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - ids.add(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID))); - } - } - - return ids; - } - - public boolean hasThread(@NonNull RecipientId recipientId) { - return getThreadIdIfExistsFor(recipientId) > -1; - } - - public void updateLastSeenAndMarkSentAndLastScrolledSilenty(long threadId) { - ContentValues contentValues = new ContentValues(3); - contentValues.put(LAST_SEEN, System.currentTimeMillis()); - contentValues.put(HAS_SENT, 1); - contentValues.put(LAST_SCROLLED, 0); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - } - - public void setHasSentSilently(long threadId, boolean hasSent) { - ContentValues contentValues = new ContentValues(1); - contentValues.put(HAS_SENT, hasSent ? 1 : 0); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, - new String[] {String.valueOf(threadId)}); - } - - void updateReadState(long threadId) { - ThreadRecord previous = getThreadRecord(threadId); - int unreadCount = SignalDatabase.mmsSms().getUnreadCount(threadId); - int unreadMentionsCount = SignalDatabase.mms().getUnreadMentionCount(threadId); - - ContentValues contentValues = new ContentValues(); - contentValues.put(READ, unreadCount == 0 ? ReadStatus.READ.serialize() : ReadStatus.UNREAD.serialize()); - contentValues.put(UNREAD_COUNT, unreadCount); - contentValues.put(UNREAD_SELF_MENTION_COUNT, unreadMentionsCount); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - - notifyConversationListListeners(); - - if (previous != null && previous.isForcedUnread()) { - SignalDatabase.recipients().markNeedsSync(previous.getRecipient().getId()); - StorageSyncHelper.scheduleSyncForDataChange(); - } - } - - public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalContactRecord record) { - applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread()); - } - - public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalGroupV1Record record) { - applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread()); - } - - public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalGroupV2Record record) { - applyStorageSyncUpdate(recipientId, record.isArchived(), record.isForcedUnread()); - } - - public void applyStorageSyncUpdate(@NonNull RecipientId recipientId, @NonNull SignalAccountRecord record) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - db.beginTransaction(); - try { - applyStorageSyncUpdate(recipientId, record.isNoteToSelfArchived(), record.isNoteToSelfForcedUnread()); - - ContentValues clearPinnedValues = new ContentValues(); - clearPinnedValues.put(PINNED, 0); - db.update(TABLE_NAME, clearPinnedValues, null, null); - - int pinnedPosition = 1; - for (SignalAccountRecord.PinnedConversation pinned : record.getPinnedConversations()) { - ContentValues pinnedValues = new ContentValues(); - pinnedValues.put(PINNED, pinnedPosition); - - Recipient pinnedRecipient; - - if (pinned.getContact().isPresent()) { - pinnedRecipient = Recipient.externalPush(pinned.getContact().get()); - } else if (pinned.getGroupV1Id().isPresent()) { - try { - pinnedRecipient = Recipient.externalGroupExact(GroupId.v1(pinned.getGroupV1Id().get())); - } catch (BadGroupIdException e) { - Log.w(TAG, "Failed to parse pinned groupV1 ID!", e); - pinnedRecipient = null; - } - } else if (pinned.getGroupV2MasterKey().isPresent()) { - try { - pinnedRecipient = Recipient.externalGroupExact(GroupId.v2(new GroupMasterKey(pinned.getGroupV2MasterKey().get()))); - } catch (InvalidInputException e) { - Log.w(TAG, "Failed to parse pinned groupV2 master key!", e); - pinnedRecipient = null; - } - } else { - Log.w(TAG, "Empty pinned conversation on the AccountRecord?"); - pinnedRecipient = null; - } - - if (pinnedRecipient != null) { - db.update(TABLE_NAME, pinnedValues, RECIPIENT_ID + " = ?", SqlUtil.buildArgs(pinnedRecipient.getId())); - } - - pinnedPosition++; - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - notifyConversationListListeners(); - } - - private void applyStorageSyncUpdate(@NonNull RecipientId recipientId, boolean archived, boolean forcedUnread) { - ContentValues values = new ContentValues(); - values.put(ARCHIVED, archived ? 1 : 0); - - Long threadId = getThreadIdFor(recipientId); - - if (forcedUnread) { - values.put(READ, ReadStatus.FORCED_UNREAD.serialize()); - } else { - if (threadId != null) { - int unreadCount = SignalDatabase.mmsSms().getUnreadCount(threadId); - int unreadMentionsCount = SignalDatabase.mms().getUnreadMentionCount(threadId); - - values.put(READ, unreadCount == 0 ? ReadStatus.READ.serialize() : ReadStatus.UNREAD.serialize()); - values.put(UNREAD_COUNT, unreadCount); - values.put(UNREAD_SELF_MENTION_COUNT, unreadMentionsCount); - } - } - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, values, RECIPIENT_ID + " = ?", SqlUtil.buildArgs(recipientId)); - - if (threadId != null) { - notifyConversationListeners(threadId); - } - } - - public boolean update(long threadId, boolean unarchive) { - return update(threadId, unarchive, true, true); - } - - boolean updateSilently(long threadId, boolean unarchive) { - return update(threadId, unarchive, true, false); - } - - public boolean update(long threadId, boolean unarchive, boolean allowDeletion) { - return update(threadId, unarchive, allowDeletion, true); - } - - private boolean update(long threadId, boolean unarchive, boolean allowDeletion, boolean notifyListeners) { - MmsSmsDatabase mmsSmsDatabase = SignalDatabase.mmsSms(); - boolean meaningfulMessages = mmsSmsDatabase.hasMeaningfulMessage(threadId); - boolean isPinned = getPinnedThreadIds().contains(threadId); - boolean shouldDelete = allowDeletion && !isPinned && !SignalDatabase.mms().containsStories(threadId); - - if (!meaningfulMessages) { - if (shouldDelete) { - deleteConversation(threadId); - return true; - } else if (!isPinned) { - return false; - } - } - - MessageRecord record; - try { - record = mmsSmsDatabase.getConversationSnippet(threadId); - } catch (NoSuchMessageException e) { - if (shouldDelete) { - deleteConversation(threadId); - } - - if (isPinned) { - updateThread(threadId, meaningfulMessages, null, null, null, null, 0, 0, 0, 0, unarchive, 0, 0); - } - - return true; - } - - updateThread(threadId, - meaningfulMessages, - ThreadBodyUtil.getFormattedBodyFor(context, record), - getAttachmentUriFor(record), - getContentTypeFor(record), - getExtrasFor(record), - record.getTimestamp(), - record.getDeliveryStatus(), - record.getDeliveryReceiptCount(), - record.getType(), - unarchive, - record.getExpiresIn(), - record.getReadReceiptCount()); - - if (notifyListeners) { - notifyConversationListListeners(); - } - - return false; - } - - public void updateSnippetTypeSilently(long threadId) { - if (threadId == -1) { - return; - } - - long type; - try { - type = SignalDatabase.mmsSms().getConversationSnippetType(threadId); - } catch (NoSuchMessageException e) { - Log.w(TAG, "Unable to find snippet message for thread: " + threadId); - return; - } - - ContentValues contentValues = new ContentValues(1); - contentValues.put(SNIPPET_TYPE, type); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(threadId)); - } - - public @NonNull ThreadRecord getThreadRecordFor(@NonNull Recipient recipient) { - return Objects.requireNonNull(getThreadRecord(getOrCreateThreadIdFor(recipient))); - } - - public @NonNull Set getAllThreadRecipients() { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - Set ids = new HashSet<>(); - - - try (Cursor cursor = db.query(TABLE_NAME, new String[] { RECIPIENT_ID }, null, null, null, null, null)) { - while (cursor.moveToNext()) { - ids.add(RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID))); - } - } - - return ids; - } - - - @NonNull MergeResult merge(@NonNull RecipientId primaryRecipientId, @NonNull RecipientId secondaryRecipientId) { - if (!databaseHelper.getSignalWritableDatabase().inTransaction()) { - throw new IllegalStateException("Must be in a transaction!"); - } - - Log.w(TAG, "Merging threads. Primary: " + primaryRecipientId + ", Secondary: " + secondaryRecipientId, true); - - ThreadRecord primary = getThreadRecord(getThreadIdFor(primaryRecipientId)); - ThreadRecord secondary = getThreadRecord(getThreadIdFor(secondaryRecipientId)); - - if (primary != null && secondary == null) { - Log.w(TAG, "[merge] Only had a thread for primary. Returning that.", true); - return new MergeResult(primary.getThreadId(), -1, false); - } else if (primary == null && secondary != null) { - Log.w(TAG, "[merge] Only had a thread for secondary. Updating it to have the recipientId of the primary.", true); - - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, primaryRecipientId.serialize()); - - databaseHelper.getSignalWritableDatabase().update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(secondary.getThreadId())); - return new MergeResult(secondary.getThreadId(), -1, false); - } else if (primary == null && secondary == null) { - Log.w(TAG, "[merge] No thread for either."); - return new MergeResult(-1, -1, false); - } else { - Log.w(TAG, "[merge] Had a thread for both. Deleting the secondary and merging the attributes together.", true); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - db.delete(TABLE_NAME, ID_WHERE, SqlUtil.buildArgs(secondary.getThreadId())); - - if (primary.getExpiresIn() != secondary.getExpiresIn()) { - ContentValues values = new ContentValues(); - if (primary.getExpiresIn() == 0) { - values.put(EXPIRES_IN, secondary.getExpiresIn()); - } else if (secondary.getExpiresIn() == 0) { - values.put(EXPIRES_IN, primary.getExpiresIn()); - } else { - values.put(EXPIRES_IN, Math.min(primary.getExpiresIn(), secondary.getExpiresIn())); - } - - db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(primary.getThreadId())); - } - - ContentValues draftValues = new ContentValues(); - draftValues.put(DraftDatabase.THREAD_ID, primary.getThreadId()); - db.update(DraftDatabase.TABLE_NAME, draftValues, DraftDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); - - ContentValues searchValues = new ContentValues(); - searchValues.put(SearchDatabase.THREAD_ID, primary.getThreadId()); - db.update(SearchDatabase.SMS_FTS_TABLE_NAME, searchValues, SearchDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); - db.update(SearchDatabase.MMS_FTS_TABLE_NAME, searchValues, SearchDatabase.THREAD_ID + " = ?", SqlUtil.buildArgs(secondary.getThreadId())); - - RemappedRecords.getInstance().addThread(secondary.getThreadId(), primary.getThreadId()); - - return new MergeResult(primary.getThreadId(), secondary.getThreadId(), true); - } - } - - public @Nullable ThreadRecord getThreadRecord(@Nullable Long threadId) { - if (threadId == null) { - return null; - } - - String query = createQuery(TABLE_NAME + "." + ID + " = ?", 1); - - try (Cursor cursor = databaseHelper.getSignalReadableDatabase().rawQuery(query, SqlUtil.buildArgs(threadId))) { - if (cursor != null && cursor.moveToFirst()) { - return readerFor(cursor).getCurrent(); - } - } - - return null; - } - - private @Nullable Uri getAttachmentUriFor(MessageRecord record) { - if (!record.isMms() || record.isMmsNotification() || record.isGroupAction()) return null; - - SlideDeck slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck(); - Slide thumbnail = OptionalUtil.or(Optional.ofNullable(slideDeck.getThumbnailSlide()), - Optional.ofNullable(slideDeck.getStickerSlide())) - .orElse(null); - - if (thumbnail != null && !((MmsMessageRecord) record).isViewOnce()) { - return thumbnail.getUri(); - } - - return null; - } - - private @Nullable String getContentTypeFor(MessageRecord record) { - if (record.isMms()) { - SlideDeck slideDeck = ((MmsMessageRecord) record).getSlideDeck(); - - if (slideDeck.getSlides().size() > 0) { - return slideDeck.getSlides().get(0).getContentType(); - } - } - - return null; - } - - private @Nullable Extra getExtrasFor(@NonNull MessageRecord record) { - Recipient threadRecipient = record.isOutgoing() ? record.getRecipient() : getRecipientForThreadId(record.getThreadId()); - boolean messageRequestAccepted = RecipientUtil.isMessageRequestAccepted(record.getThreadId(), threadRecipient); - RecipientId individualRecipientId = record.getIndividualRecipient().getId(); - - //noinspection ConstantConditions - if (!messageRequestAccepted && threadRecipient != null) { - if (threadRecipient.isPushGroup()) { - if (threadRecipient.isPushV2Group()) { - MessageRecord.InviteAddState inviteAddState = record.getGv2AddInviteState(); - if (inviteAddState != null) { - RecipientId from = RecipientId.from(ServiceId.from(inviteAddState.getAddedOrInvitedBy())); - if (inviteAddState.isInvited()) { - Log.i(TAG, "GV2 invite message request from " + from); - return Extra.forGroupV2invite(from, individualRecipientId); - } else { - Log.i(TAG, "GV2 message request from " + from); - return Extra.forGroupMessageRequest(from, individualRecipientId); - } - } - Log.w(TAG, "Falling back to unknown message request state for GV2 message"); - return Extra.forMessageRequest(individualRecipientId); - } else { - RecipientId recipientId = SignalDatabase.mmsSms().getGroupAddedBy(record.getThreadId()); - - if (recipientId != null) { - return Extra.forGroupMessageRequest(recipientId, individualRecipientId); - } - } - } - - return Extra.forMessageRequest(individualRecipientId); - } - - if (record.isRemoteDelete()) { - return Extra.forRemoteDelete(individualRecipientId); - } else if (record.isViewOnce()) { - return Extra.forViewOnce(individualRecipientId); - } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getStickerSlide() != null) { - StickerSlide slide = Objects.requireNonNull(((MmsMessageRecord) record).getSlideDeck().getStickerSlide()); - return Extra.forSticker(slide.getEmoji(), individualRecipientId); - } else if (record.isMms() && ((MmsMessageRecord) record).getSlideDeck().getSlides().size() > 1) { - return Extra.forAlbum(individualRecipientId); - } - - if (threadRecipient != null && threadRecipient.isGroup()) { - return Extra.forDefault(individualRecipientId); - } - - return null; - } - - private @NonNull String createQuery(@NonNull String where, long limit) { - return createQuery(where, 0, limit, false); - } - - private @NonNull String createQuery(@NonNull String where, long offset, long limit, boolean preferPinned) { - String orderBy = (preferPinned ? TABLE_NAME + "." + PINNED + " DESC, " : "") + TABLE_NAME + "." + DATE + " DESC"; - - return createQuery(where, orderBy, offset, limit); - } - - private @NonNull String createQuery(@NonNull String where, @NonNull String orderBy, long offset, long limit) { - String projection = Util.join(COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION, ","); - - String query = - "SELECT " + projection + " FROM " + TABLE_NAME + - " LEFT OUTER JOIN " + RecipientDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + RecipientDatabase.TABLE_NAME + "." + RecipientDatabase.ID + - " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + - " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.RECIPIENT_ID + - " WHERE " + where + - " ORDER BY " + orderBy; - - if (limit > 0) { - query += " LIMIT " + limit; - } - - if (offset > 0) { - query += " OFFSET " + offset; - } - - return query; - } - - private boolean isSilentType(long type) { - return MmsSmsColumns.Types.isProfileChange(type) || - MmsSmsColumns.Types.isGroupV1MigrationEvent(type) || - MmsSmsColumns.Types.isChangeNumber(type) || - MmsSmsColumns.Types.isBoostRequest(type) || - MmsSmsColumns.Types.isGroupV2LeaveOnly(type) || - MmsSmsColumns.Types.isThreadMergeType(type); - } - - public Reader readerFor(Cursor cursor) { - return new Reader(cursor); - } - - public static class DistributionTypes { - public static final int DEFAULT = 2; - public static final int BROADCAST = 1; - public static final int CONVERSATION = 2; - public static final int ARCHIVE = 3; - public static final int INBOX_ZERO = 4; - } - - public class Reader extends StaticReader { - public Reader(Cursor cursor) { - super(cursor, context); - } - } - - public static class StaticReader implements Closeable { - - private final Cursor cursor; - private final Context context; - - public StaticReader(Cursor cursor, Context context) { - this.cursor = cursor; - this.context = context; - } - - public ThreadRecord getNext() { - if (cursor == null || !cursor.moveToNext()) - return null; - - return getCurrent(); - } - - public ThreadRecord getCurrent() { - RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, ThreadDatabase.RECIPIENT_ID)); - RecipientRecord recipientSettings = SignalDatabase.recipients().getRecord(context, cursor, ThreadDatabase.RECIPIENT_ID); - - Recipient recipient; - - if (recipientSettings.getGroupId() != null) { - GroupDatabase.GroupRecord group = new GroupDatabase.Reader(cursor).getCurrent(); - - if (group != null) { - RecipientDetails details = new RecipientDetails(group.getTitle(), - null, - group.hasAvatar() ? Optional.of(group.getAvatarId()) : Optional.empty(), - false, - false, - recipientSettings.getRegistered(), - recipientSettings, - null, - false); - - recipient = new Recipient(recipientId, details, false); - } else { - recipient = Recipient.live(recipientId).get(); - } - } else { - RecipientDetails details = RecipientDetails.forIndividual(context, recipientSettings); - recipient = new Recipient(recipientId, details, true); - } - - int readReceiptCount = TextSecurePreferences.isReadReceiptsEnabled(context) ? cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT)) - : 0; - - String extraString = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_EXTRAS)); - Extra extra = null; - - if (extraString != null) { - try { - extra = JsonUtils.fromJson(extraString, Extra.class); - } catch (IOException e) { - Log.w(TAG, "Failed to decode extras!"); - } - } - - return new ThreadRecord.Builder(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID))) - .setRecipient(recipient) - .setType(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE))) - .setDistributionType(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.TYPE))) - .setBody(Util.emptyIfNull(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET)))) - .setDate(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE))) - .setArchived(CursorUtil.requireInt(cursor, ThreadDatabase.ARCHIVED) != 0) - .setDeliveryStatus(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS))) - .setDeliveryReceiptCount(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT))) - .setReadReceiptCount(readReceiptCount) - .setExpiresIn(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN))) - .setLastSeen(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN))) - .setSnippetUri(getSnippetUri(cursor)) - .setContentType(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_CONTENT_TYPE))) - .setMeaningfulMessages(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MEANINGFUL_MESSAGES)) > 0) - .setUnreadCount(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT))) - .setForcedUnread(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ)) == ReadStatus.FORCED_UNREAD.serialize()) - .setPinned(CursorUtil.requireBoolean(cursor, ThreadDatabase.PINNED)) - .setUnreadSelfMentionsCount(CursorUtil.requireInt(cursor, ThreadDatabase.UNREAD_SELF_MENTION_COUNT)) - .setExtra(extra) - .build(); - } - - private @Nullable Uri getSnippetUri(Cursor cursor) { - if (cursor.isNull(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_URI))) { - return null; - } - - try { - return Uri.parse(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_URI))); - } catch (IllegalArgumentException e) { - Log.w(TAG, e); - return null; - } - } - - @Override - public void close() { - if (cursor != null) { - cursor.close(); - } - } - } - - public static final class Extra { - - @JsonProperty private final boolean isRevealable; - @JsonProperty private final boolean isSticker; - @JsonProperty private final String stickerEmoji; - @JsonProperty private final boolean isAlbum; - @JsonProperty private final boolean isRemoteDelete; - @JsonProperty private final boolean isMessageRequestAccepted; - @JsonProperty private final boolean isGv2Invite; - @JsonProperty private final String groupAddedBy; - @JsonProperty private final String individualRecipientId; - - public Extra(@JsonProperty("isRevealable") boolean isRevealable, - @JsonProperty("isSticker") boolean isSticker, - @JsonProperty("stickerEmoji") String stickerEmoji, - @JsonProperty("isAlbum") boolean isAlbum, - @JsonProperty("isRemoteDelete") boolean isRemoteDelete, - @JsonProperty("isMessageRequestAccepted") boolean isMessageRequestAccepted, - @JsonProperty("isGv2Invite") boolean isGv2Invite, - @JsonProperty("groupAddedBy") String groupAddedBy, - @JsonProperty("individualRecipientId") String individualRecipientId) - { - this.isRevealable = isRevealable; - this.isSticker = isSticker; - this.stickerEmoji = stickerEmoji; - this.isAlbum = isAlbum; - this.isRemoteDelete = isRemoteDelete; - this.isMessageRequestAccepted = isMessageRequestAccepted; - this.isGv2Invite = isGv2Invite; - this.groupAddedBy = groupAddedBy; - this.individualRecipientId = individualRecipientId; - } - - public static @NonNull Extra forViewOnce(@NonNull RecipientId individualRecipient) { - return new Extra(true, false, null, false, false, true, false, null, individualRecipient.serialize()); - } - - public static @NonNull Extra forSticker(@Nullable String emoji, @NonNull RecipientId individualRecipient) { - return new Extra(false, true, emoji, false, false, true, false, null, individualRecipient.serialize()); - } - - public static @NonNull Extra forAlbum(@NonNull RecipientId individualRecipient) { - return new Extra(false, false, null, true, false, true, false, null, individualRecipient.serialize()); - } - - public static @NonNull Extra forRemoteDelete(@NonNull RecipientId individualRecipient) { - return new Extra(false, false, null, false, true, true, false, null, individualRecipient.serialize()); - } - - public static @NonNull Extra forMessageRequest(@NonNull RecipientId individualRecipient) { - return new Extra(false, false, null, false, false, false, false, null, individualRecipient.serialize()); - } - - public static @NonNull Extra forGroupMessageRequest(@NonNull RecipientId recipientId, @NonNull RecipientId individualRecipient) { - return new Extra(false, false, null, false, false, false, false, recipientId.serialize(), individualRecipient.serialize()); - } - - public static @NonNull Extra forGroupV2invite(@NonNull RecipientId recipientId, @NonNull RecipientId individualRecipient) { - return new Extra(false, false, null, false, false, false, true, recipientId.serialize(), individualRecipient.serialize()); - } - - public static @NonNull Extra forDefault(@NonNull RecipientId individualRecipient) { - return new Extra(false, false, null, false, false, true, false, null, individualRecipient.serialize()); - } - - public boolean isViewOnce() { - return isRevealable; - } - - public boolean isSticker() { - return isSticker; - } - - public @Nullable String getStickerEmoji() { - return stickerEmoji; - } - - public boolean isAlbum() { - return isAlbum; - } - - public boolean isRemoteDelete() { - return isRemoteDelete; - } - - public boolean isMessageRequestAccepted() { - return isMessageRequestAccepted; - } - - public boolean isGv2Invite() { - return isGv2Invite; - } - - public @Nullable String getGroupAddedBy() { - return groupAddedBy; - } - - public @Nullable String getIndividualRecipientId() { - return individualRecipientId; - } - - @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Extra extra = (Extra) o; - return isRevealable == extra.isRevealable && - isSticker == extra.isSticker && - isAlbum == extra.isAlbum && - isRemoteDelete == extra.isRemoteDelete && - isMessageRequestAccepted == extra.isMessageRequestAccepted && - isGv2Invite == extra.isGv2Invite && - Objects.equals(stickerEmoji, extra.stickerEmoji) && - Objects.equals(groupAddedBy, extra.groupAddedBy) && - Objects.equals(individualRecipientId, extra.individualRecipientId); - } - - @Override public int hashCode() { - return Objects.hash(isRevealable, - isSticker, - stickerEmoji, - isAlbum, - isRemoteDelete, - isMessageRequestAccepted, - isGv2Invite, - groupAddedBy, - individualRecipientId); - } - } - - enum ReadStatus { - READ(1), UNREAD(0), FORCED_UNREAD(2); - - private final int value; - - ReadStatus(int value) { - this.value = value; - } - - public static ReadStatus deserialize(int value) { - for (ReadStatus status : ReadStatus.values()) { - if (status.value == value) { - return status; - } - } - throw new IllegalArgumentException("No matching status for value " + value); - } - - public int serialize() { - return value; - } - } - - public static class ConversationMetadata { - private final long lastSeen; - private final boolean hasSent; - private final long lastScrolled; - - public ConversationMetadata(long lastSeen, boolean hasSent, long lastScrolled) { - this.lastSeen = lastSeen; - this.hasSent = hasSent; - this.lastScrolled = lastScrolled; - } - - public long getLastSeen() { - return lastSeen; - } - - public boolean hasSent() { - return hasSent; - } - - public long getLastScrolled() { - return lastScrolled; - } - } - - static final class MergeResult { - final long threadId; - final long previousThreadId; - final boolean neededMerge; - - private MergeResult(long threadId, long previousThreadId, boolean neededMerge) { - this.threadId = threadId; - this.previousThreadId = previousThreadId; - this.neededMerge = neededMerge; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt new file mode 100644 index 0000000000..098fe4a96b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt @@ -0,0 +1,1763 @@ +package org.thoughtcrime.securesms.database + +import android.annotation.SuppressLint +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.MergeCursor +import android.net.Uri +import androidx.core.content.contentValuesOf +import com.fasterxml.jackson.annotation.JsonProperty +import org.jsoup.helper.StringUtil +import org.signal.core.util.CursorUtil +import org.signal.core.util.SqlUtil +import org.signal.core.util.delete +import org.signal.core.util.logging.Log +import org.signal.core.util.or +import org.signal.core.util.readToList +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireString +import org.signal.core.util.select +import org.signal.core.util.update +import org.signal.core.util.withinTransaction +import org.signal.libsignal.zkgroup.InvalidInputException +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.thoughtcrime.securesms.database.MessageDatabase.MarkedMessageInfo +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.drafts +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupReceipts +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mentions +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messageLog +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mms +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mmsSms +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.sms +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.groups.BadGroupIdException +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.mms.StickerSlide +import org.thoughtcrime.securesms.notifications.v2.ConversationId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientDetails +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientUtil +import org.thoughtcrime.securesms.storage.StorageSyncHelper +import org.thoughtcrime.securesms.util.ConversationUtil +import org.thoughtcrime.securesms.util.JsonUtils +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.storage.SignalAccountRecord +import org.whispersystems.signalservice.api.storage.SignalAccountRecord.PinnedConversation +import org.whispersystems.signalservice.api.storage.SignalContactRecord +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record +import java.io.Closeable +import java.io.IOException +import java.util.Collections +import java.util.LinkedList +import java.util.Optional +import kotlin.math.max +import kotlin.math.min + +@SuppressLint("RecipientIdDatabaseReferenceUsage", "ThreadIdDatabaseReferenceUsage") // Handles remapping in a unique way +class ThreadDatabase(context: Context, databaseHelper: SignalDatabase) : Database(context, databaseHelper) { + + companion object { + private val TAG = Log.tag(ThreadDatabase::class.java) + + const val TABLE_NAME = "thread" + const val ID = "_id" + const val DATE = "date" + const val MEANINGFUL_MESSAGES = "message_count" + const val RECIPIENT_ID = "thread_recipient_id" + const val SNIPPET = "snippet" + const val SNIPPET_CHARSET = "snippet_charset" + const val READ = "read" + const val UNREAD_COUNT = "unread_count" + const val TYPE = "type" + const val ERROR = "error" + const val SNIPPET_TYPE = "snippet_type" + const val SNIPPET_URI = "snippet_uri" + const val SNIPPET_CONTENT_TYPE = "snippet_content_type" + const val SNIPPET_EXTRAS = "snippet_extras" + const val ARCHIVED = "archived" + const val STATUS = "status" + const val DELIVERY_RECEIPT_COUNT = "delivery_receipt_count" + const val READ_RECEIPT_COUNT = "read_receipt_count" + const val EXPIRES_IN = "expires_in" + const val LAST_SEEN = "last_seen" + const val HAS_SENT = "has_sent" + const val LAST_SCROLLED = "last_scrolled" + const val PINNED = "pinned" + const val UNREAD_SELF_MENTION_COUNT = "unread_self_mention_count" + + @JvmField + val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY AUTOINCREMENT, + $DATE INTEGER DEFAULT 0, + $MEANINGFUL_MESSAGES INTEGER DEFAULT 0, + $RECIPIENT_ID INTEGER, + $SNIPPET TEXT, + $SNIPPET_CHARSET INTEGER DEFAULT 0, + $READ INTEGER DEFAULT ${ReadStatus.READ.serialize()}, + $TYPE INTEGER DEFAULT 0, + $ERROR INTEGER DEFAULT 0, + $SNIPPET_TYPE INTEGER DEFAULT 0, + $SNIPPET_URI TEXT DEFAULT NULL, + $SNIPPET_CONTENT_TYPE TEXT DEFAULT NULL, + $SNIPPET_EXTRAS TEXT DEFAULT NULL, + $ARCHIVED INTEGER DEFAULT 0, + $STATUS INTEGER DEFAULT 0, + $DELIVERY_RECEIPT_COUNT INTEGER DEFAULT 0, + $EXPIRES_IN INTEGER DEFAULT 0, + $LAST_SEEN INTEGER DEFAULT 0, + $HAS_SENT INTEGER DEFAULT 0, + $READ_RECEIPT_COUNT INTEGER DEFAULT 0, + $UNREAD_COUNT INTEGER DEFAULT 0, + $LAST_SCROLLED INTEGER DEFAULT 0, + $PINNED INTEGER DEFAULT 0, + $UNREAD_SELF_MENTION_COUNT INTEGER DEFAULT 0 + ) + """.trimIndent() + + @JvmField + val CREATE_INDEXS = arrayOf( + "CREATE INDEX IF NOT EXISTS thread_recipient_id_index ON $TABLE_NAME ($RECIPIENT_ID);", + "CREATE INDEX IF NOT EXISTS archived_count_index ON $TABLE_NAME ($ARCHIVED, $MEANINGFUL_MESSAGES);", + "CREATE INDEX IF NOT EXISTS thread_pinned_index ON $TABLE_NAME ($PINNED);" + ) + + private val THREAD_PROJECTION = arrayOf( + ID, + DATE, + MEANINGFUL_MESSAGES, + RECIPIENT_ID, + SNIPPET, + SNIPPET_CHARSET, + READ, + UNREAD_COUNT, + TYPE, + ERROR, + SNIPPET_TYPE, + SNIPPET_URI, + SNIPPET_CONTENT_TYPE, + SNIPPET_EXTRAS, + ARCHIVED, + STATUS, + DELIVERY_RECEIPT_COUNT, + EXPIRES_IN, + LAST_SEEN, + READ_RECEIPT_COUNT, + LAST_SCROLLED, + PINNED, + UNREAD_SELF_MENTION_COUNT + ) + + private val TYPED_THREAD_PROJECTION: List = THREAD_PROJECTION + .map { columnName: String -> "$TABLE_NAME.$columnName" } + .toList() + + private val COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION: List = TYPED_THREAD_PROJECTION + RecipientDatabase.TYPED_RECIPIENT_PROJECTION_NO_ID + GroupDatabase.TYPED_GROUP_PROJECTION + + const val NO_TRIM_BEFORE_DATE_SET: Long = 0 + const val NO_TRIM_MESSAGE_COUNT_SET = Int.MAX_VALUE + } + + private fun createThreadForRecipient(recipientId: RecipientId, group: Boolean, distributionType: Int): Long { + if (recipientId.isUnknown) { + throw AssertionError("Cannot create a thread for an unknown recipient!") + } + + val date = System.currentTimeMillis() + val contentValues = contentValuesOf( + DATE to date - date % 1000, + RECIPIENT_ID to recipientId.serialize(), + MEANINGFUL_MESSAGES to 0 + ) + + if (group) { + contentValues.put(TYPE, distributionType) + } + + val result = writableDatabase.insert(TABLE_NAME, null, contentValues) + Recipient.live(recipientId).refresh() + return result + } + + private fun updateThread( + threadId: Long, + meaningfulMessages: Boolean, + body: String?, + attachment: Uri?, + contentType: String?, + extra: Extra?, + date: Long, + status: Int, + deliveryReceiptCount: Int, + type: Long, + unarchive: Boolean, + expiresIn: Long, + readReceiptCount: Int + ) { + var extraSerialized: String? = null + + if (extra != null) { + extraSerialized = try { + JsonUtils.toJson(extra) + } catch (e: IOException) { + throw AssertionError(e) + } + } + + val contentValues = contentValuesOf( + DATE to date - date % 1000, + SNIPPET to body, + SNIPPET_URI to attachment?.toString(), + SNIPPET_TYPE to type, + SNIPPET_CONTENT_TYPE to contentType, + SNIPPET_EXTRAS to extraSerialized, + MEANINGFUL_MESSAGES to if (meaningfulMessages) 1 else 0, + STATUS to status, + DELIVERY_RECEIPT_COUNT to deliveryReceiptCount, + READ_RECEIPT_COUNT to readReceiptCount, + EXPIRES_IN to expiresIn + ) + + writableDatabase + .update(TABLE_NAME) + .values(contentValues) + .where("$ID = ?", threadId) + .run() + + if (unarchive) { + val archiveValues = contentValuesOf(ARCHIVED to 0) + val query = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(threadId), archiveValues) + if (writableDatabase.update(TABLE_NAME, archiveValues, query.where, query.whereArgs) > 0) { + StorageSyncHelper.scheduleSyncForDataChange() + } + } + } + + fun updateSnippetUriSilently(threadId: Long, attachment: Uri?) { + writableDatabase + .update(TABLE_NAME) + .values(SNIPPET_URI to attachment?.toString()) + .where("$ID = ?", threadId) + .run() + } + + fun updateSnippet(threadId: Long, snippet: String?, attachment: Uri?, date: Long, type: Long, unarchive: Boolean) { + if (isSilentType(type)) { + return + } + + val contentValues = contentValuesOf( + DATE to date - date % 1000, + SNIPPET to snippet, + SNIPPET_TYPE to type, + SNIPPET_URI to attachment?.toString() + ) + + if (unarchive) { + contentValues.put(ARCHIVED, 0) + } + + writableDatabase + .update(TABLE_NAME) + .values(contentValues) + .where("$ID = ?", threadId) + .run() + + notifyConversationListListeners() + } + + fun trimAllThreads(length: Int, trimBeforeDate: Long) { + if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { + return + } + + readableDatabase + .select(ID) + .from(TABLE_NAME) + .run() + .use { cursor -> + while (cursor.moveToNext()) { + trimThreadInternal(cursor.requireLong(ID), length, trimBeforeDate) + } + } + + val deletes = writableDatabase.withinTransaction { + mmsSms.deleteAbandonedMessages() + attachments.trimAllAbandonedAttachments() + groupReceipts.deleteAbandonedRows() + mentions.deleteAbandonedMentions() + return@withinTransaction attachments.deleteAbandonedAttachmentFiles() + } + + if (deletes > 0) { + Log.i(TAG, "Trim all threads caused $deletes attachments to be deleted.") + } + + notifyAttachmentListeners() + notifyStickerPackListeners() + } + + fun trimThread(threadId: Long, length: Int, trimBeforeDate: Long) { + if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { + return + } + + val deletes = writableDatabase.withinTransaction { + trimThreadInternal(threadId, length, trimBeforeDate) + mmsSms.deleteAbandonedMessages() + attachments.trimAllAbandonedAttachments() + groupReceipts.deleteAbandonedRows() + mentions.deleteAbandonedMentions() + return@withinTransaction attachments.deleteAbandonedAttachmentFiles() + } + + if (deletes > 0) { + Log.i(TAG, "Trim thread $threadId caused $deletes attachments to be deleted.") + } + + notifyAttachmentListeners() + notifyStickerPackListeners() + } + + private fun trimThreadInternal(threadId: Long, length: Int, trimBeforeDate: Long) { + if (length == NO_TRIM_MESSAGE_COUNT_SET && trimBeforeDate == NO_TRIM_BEFORE_DATE_SET) { + return + } + + val finalTrimBeforeDate = if (length != NO_TRIM_MESSAGE_COUNT_SET && length > 0) { + mmsSms.getConversation(threadId).use { cursor -> + if (cursor.count > length) { + cursor.moveToPosition(length - 1) + max(trimBeforeDate, cursor.requireLong(MmsSmsColumns.NORMALIZED_DATE_RECEIVED)) + } else { + trimBeforeDate + } + } + } else { + trimBeforeDate + } + + if (finalTrimBeforeDate != NO_TRIM_BEFORE_DATE_SET) { + Log.i(TAG, "Trimming thread: $threadId before: $finalTrimBeforeDate") + + val deletes = mmsSms.deleteMessagesInThreadBeforeDate(threadId, finalTrimBeforeDate) + if (deletes > 0) { + Log.i(TAG, "Trimming deleted $deletes messages thread: $threadId") + setLastScrolled(threadId, 0) + update(threadId, false) + notifyConversationListeners(threadId) + } else { + Log.i(TAG, "Trimming deleted no messages thread: $threadId") + } + } + } + + fun setAllThreadsRead(): List { + writableDatabase + .update(TABLE_NAME) + .values( + READ to ReadStatus.READ.serialize(), + UNREAD_COUNT to 0, + UNREAD_SELF_MENTION_COUNT to 0 + ) + .run() + + val smsRecords = sms.setAllMessagesRead() + val mmsRecords = mms.setAllMessagesRead() + + sms.setAllReactionsSeen() + mms.setAllReactionsSeen() + notifyConversationListListeners() + + return smsRecords + mmsRecords + } + + fun hasCalledSince(recipient: Recipient, timestamp: Long): Boolean { + return hasReceivedAnyCallsSince(getOrCreateThreadIdFor(recipient), timestamp) + } + + fun hasReceivedAnyCallsSince(threadId: Long, timestamp: Long): Boolean { + return mmsSms.hasReceivedAnyCallsSince(threadId, timestamp) + } + + fun setEntireThreadRead(threadId: Long): List { + setRead(threadId, false) + return sms.setEntireThreadRead(threadId) + mms.setEntireThreadRead(threadId) + } + + fun setRead(threadId: Long, lastSeen: Boolean): List { + return setReadSince(Collections.singletonMap(threadId, -1L), lastSeen) + } + + fun setRead(conversationId: ConversationId, lastSeen: Boolean): List { + return if (conversationId.groupStoryId == null) { + setRead(conversationId.threadId, lastSeen) + } else { + setGroupStoryReadSince(conversationId.threadId, conversationId.groupStoryId, System.currentTimeMillis()) + } + } + + fun setReadSince(conversationId: ConversationId, lastSeen: Boolean, sinceTimestamp: Long): List { + return if (conversationId.groupStoryId != null) { + setGroupStoryReadSince(conversationId.threadId, conversationId.groupStoryId, sinceTimestamp) + } else { + setReadSince(conversationId.threadId, lastSeen, sinceTimestamp) + } + } + + fun setReadSince(threadId: Long, lastSeen: Boolean, sinceTimestamp: Long): List { + return setReadSince(Collections.singletonMap(threadId, sinceTimestamp), lastSeen) + } + + fun setRead(threadIds: Collection, lastSeen: Boolean): List { + return setReadSince(threadIds.associateWith { -1L }, lastSeen) + } + + private fun setGroupStoryReadSince(threadId: Long, groupStoryId: Long, sinceTimestamp: Long): List { + return mms.setGroupStoryMessagesReadSince(threadId, groupStoryId, sinceTimestamp) + } + + fun setReadSince(threadIdToSinceTimestamp: Map, lastSeen: Boolean): List { + val smsRecords: MutableList = LinkedList() + val mmsRecords: MutableList = LinkedList() + var needsSync = false + + writableDatabase.withinTransaction { db -> + for ((threadId, sinceTimestamp) in threadIdToSinceTimestamp) { + val previous = getThreadRecord(threadId) + + smsRecords += sms.setMessagesReadSince(threadId, sinceTimestamp) + mmsRecords += mms.setMessagesReadSince(threadId, sinceTimestamp) + + sms.setReactionsSeen(threadId, sinceTimestamp) + mms.setReactionsSeen(threadId, sinceTimestamp) + + val unreadCount = mmsSms.getUnreadCount(threadId) + val unreadMentionsCount = mms.getUnreadMentionCount(threadId) + + val contentValues = contentValuesOf( + READ to ReadStatus.READ.serialize(), + UNREAD_COUNT to unreadCount, + UNREAD_SELF_MENTION_COUNT to unreadMentionsCount + ) + + if (lastSeen) { + contentValues.put(LAST_SEEN, if (sinceTimestamp == -1L) System.currentTimeMillis() else sinceTimestamp) + } + + db.update(TABLE_NAME) + .values(contentValues) + .where("$ID = ?", threadId) + .run() + + if (previous != null && previous.isForcedUnread) { + recipients.markNeedsSync(previous.recipient.id) + needsSync = true + } + } + } + + notifyVerboseConversationListeners(threadIdToSinceTimestamp.keys) + notifyConversationListListeners() + + if (needsSync) { + StorageSyncHelper.scheduleSyncForDataChange() + } + + return smsRecords + mmsRecords + } + + fun setForcedUnread(threadIds: Collection) { + var recipientIds: List = emptyList() + + writableDatabase.withinTransaction { db -> + val query = SqlUtil.buildSingleCollectionQuery(ID, threadIds) + val contentValues = contentValuesOf(READ to ReadStatus.FORCED_UNREAD.serialize()) + db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) + + recipientIds = getRecipientIdsForThreadIds(threadIds) + recipients.markNeedsSyncWithoutRefresh(recipientIds) + } + + for (id in recipientIds) { + Recipient.live(id).refresh() + } + + StorageSyncHelper.scheduleSyncForDataChange() + notifyConversationListListeners() + } + + fun getUnreadThreadCount(): Long { + return getUnreadThreadIdAggregate(SqlUtil.COUNT) { cursor -> + if (cursor.moveToFirst()) { + cursor.getLong(0) + } else { + 0L + } + } + } + + fun getUnreadMessageCount(threadId: Long): Long { + return readableDatabase + .select(UNREAD_COUNT) + .from(TABLE_NAME) + .where("$ID = ?", threadId) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + CursorUtil.requireLong(cursor, UNREAD_COUNT) + } else { + 0L + } + } + } + + fun getUnreadThreadIdList(): String? { + return getUnreadThreadIdAggregate(arrayOf("GROUP_CONCAT($ID)")) { cursor -> + if (cursor.moveToFirst()) { + cursor.getString(0) + } else { + null + } + } + } + + private fun getUnreadThreadIdAggregate(aggregator: Array, mapCursorToType: (Cursor) -> T): T { + return readableDatabase + .select(*aggregator) + .from(TABLE_NAME) + .where("$READ != ${ReadStatus.READ.serialize()} AND $ARCHIVED = 0 AND $MEANINGFUL_MESSAGES != 0") + .run() + .use(mapCursorToType) + } + + fun incrementUnread(threadId: Long, unreadAmount: Int, unreadSelfMentionAmount: Int) { + writableDatabase.execSQL( + """ + UPDATE $TABLE_NAME + SET $READ = ${ReadStatus.UNREAD.serialize()}, + $UNREAD_COUNT = $UNREAD_COUNT + ?, + $UNREAD_SELF_MENTION_COUNT = $UNREAD_SELF_MENTION_COUNT + ?, + $LAST_SCROLLED = ? + WHERE $ID = ? + """.toSingleLine(), + SqlUtil.buildArgs(unreadAmount, unreadSelfMentionAmount, 0, threadId) + ) + } + + fun setDistributionType(threadId: Long, distributionType: Int) { + writableDatabase + .update(TABLE_NAME) + .values(TYPE to distributionType) + .where("$ID = ?", threadId) + .run() + + notifyConversationListListeners() + } + + fun getDistributionType(threadId: Long): Int { + return readableDatabase + .select(TYPE) + .from(TABLE_NAME) + .where("$ID = ?", threadId) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + cursor.requireInt(TYPE) + } else { + DistributionTypes.DEFAULT + } + } + } + + fun getFilteredConversationList(filter: List): Cursor? { + if (filter.isEmpty()) { + return null + } + + val db = databaseHelper.signalReadableDatabase + val splitRecipientIds: List> = filter.chunked(900) + val cursors: MutableList = LinkedList() + + for (recipientIds in splitRecipientIds) { + var selection = "$TABLE_NAME.$RECIPIENT_ID = ?" + val selectionArgs = arrayOfNulls(recipientIds.size) + + for (i in 0 until recipientIds.size - 1) { + selection += " OR $TABLE_NAME.$RECIPIENT_ID = ?" + } + + var i = 0 + for (recipientId in recipientIds) { + selectionArgs[i] = recipientId.serialize() + i++ + } + + val query = createQuery(selection, "$DATE DESC", 0, 0) + cursors.add(db.rawQuery(query, selectionArgs)) + } + + return if (cursors.size > 1) { + MergeCursor(cursors.toTypedArray()) + } else { + cursors[0] + } + } + + fun getRecentConversationList(limit: Int, includeInactiveGroups: Boolean, hideV1Groups: Boolean): Cursor { + return getRecentConversationList( + limit = limit, + includeInactiveGroups = includeInactiveGroups, + individualsOnly = false, + groupsOnly = false, + hideV1Groups = hideV1Groups, + hideSms = false, + hideSelf = false + ) + } + + fun getRecentConversationList(limit: Int, includeInactiveGroups: Boolean, individualsOnly: Boolean, groupsOnly: Boolean, hideV1Groups: Boolean, hideSms: Boolean, hideSelf: Boolean): Cursor { + var where = "" + + if (!includeInactiveGroups) { + where += "$MEANINGFUL_MESSAGES != 0 AND (${GroupDatabase.TABLE_NAME}.${GroupDatabase.ACTIVE} IS NULL OR ${GroupDatabase.TABLE_NAME}.${GroupDatabase.ACTIVE} = 1)" + } else { + where += "$MEANINGFUL_MESSAGES != 0" + } + + if (groupsOnly) { + where += " AND ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.GROUP_ID} NOT NULL" + } + + if (individualsOnly) { + where += " AND ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.GROUP_ID} IS NULL" + } + + if (hideV1Groups) { + where += " AND ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.GROUP_TYPE} != ${RecipientDatabase.GroupType.SIGNAL_V1.id}" + } + + if (hideSms) { + where += """ AND ( + ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.REGISTERED} = ${RecipientDatabase.RegisteredState.REGISTERED.id} + OR + ( + ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.GROUP_ID} NOT NULL + AND ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.GROUP_TYPE} != ${RecipientDatabase.GroupType.MMS.id} + ) + )""" + where += " AND ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.FORCE_SMS_SELECTION} = 0" + } + + if (hideSelf) { + where += " AND $RECIPIENT_ID != ${Recipient.self().id.toLong()}" + } + + where += " AND $ARCHIVED = 0" + where += " AND ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.BLOCKED} = 0" + + if (SignalStore.releaseChannelValues().releaseChannelRecipientId != null) { + where += " AND $RECIPIENT_ID != ${SignalStore.releaseChannelValues().releaseChannelRecipientId!!.toLong()}" + } + + val query = createQuery( + where = where, + offset = 0, + limit = limit.toLong(), + preferPinned = true + ) + + return readableDatabase.rawQuery(query, null) + } + + fun getRecentPushConversationList(limit: Int, includeInactiveGroups: Boolean): Cursor { + val activeGroupQuery = if (!includeInactiveGroups) " AND " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.ACTIVE + " = 1" else "" + val where = """ + $MEANINGFUL_MESSAGES != 0 + AND ( + ${RecipientDatabase.REGISTERED} = ${RecipientDatabase.RegisteredState.REGISTERED.id} + OR ( + ${GroupDatabase.TABLE_NAME}.${GroupDatabase.GROUP_ID} NOT NULL + AND ${GroupDatabase.TABLE_NAME}.${GroupDatabase.MMS} = 0 + $activeGroupQuery + ) + ) + """ + + val query = createQuery( + where = where, + offset = 0, + limit = limit.toLong(), + preferPinned = true + ) + + return readableDatabase.rawQuery(query, null) + } + + fun isArchived(recipientId: RecipientId): Boolean { + return readableDatabase + .select(ARCHIVED) + .from(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + cursor.requireBoolean(ARCHIVED) + } else { + false + } + } + } + + fun setArchived(threadIds: Set, archive: Boolean) { + var recipientIds: List = emptyList() + + writableDatabase.withinTransaction { db -> + for (threadId in threadIds) { + val values = ContentValues().apply { + if (archive) { + put(PINNED, "0") + put(ARCHIVED, "1") + } else { + put(ARCHIVED, "0") + } + } + + db.update(TABLE_NAME) + .values(values) + .where("$ID = ?", threadId) + .run() + } + + recipientIds = getRecipientIdsForThreadIds(threadIds) + recipients.markNeedsSyncWithoutRefresh(recipientIds) + } + + for (id in recipientIds) { + Recipient.live(id).refresh() + } + notifyConversationListListeners() + StorageSyncHelper.scheduleSyncForDataChange() + } + + fun getArchivedRecipients(): Set { + return getArchivedConversationList().readToList { cursor -> + RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + }.toSet() + } + + fun getInboxPositions(): Map { + val query = createQuery("$MEANINGFUL_MESSAGES != ?", 0) + val positions: MutableMap = mutableMapOf() + + readableDatabase.rawQuery(query, arrayOf("0")).use { cursor -> + var i = 0 + while (cursor != null && cursor.moveToNext()) { + val recipientId = RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + positions[recipientId] = i + i++ + } + } + + return positions + } + + fun getArchivedConversationList(offset: Long = 0, limit: Long = 0): Cursor { + val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0", offset, limit, preferPinned = false) + return readableDatabase.rawQuery(query, arrayOf("1")) + } + + fun getUnarchivedConversationList(pinned: Boolean, offset: Long, limit: Long): Cursor { + val where = if (pinned) { + "$ARCHIVED = 0 AND $PINNED != 0" + } else { + "$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0" + } + + val query = if (pinned) { + createQuery(where, PINNED + " ASC", offset, limit) + } else { + createQuery(where, offset, limit, preferPinned = false) + } + + return readableDatabase.rawQuery(query, null) + } + + fun getArchivedConversationListCount(): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0") + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + cursor.getInt(0) + } else { + 0 + } + } + } + + fun getPinnedConversationListCount(): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$ARCHIVED = 0 AND $PINNED != 0") + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + cursor.getInt(0) + } else { + 0 + } + } + } + + fun getUnarchivedConversationListCount(): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0)") + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + cursor.getInt(0) + } else { + 0 + } + } + } + + /** + * @return Pinned recipients, in order from top to bottom. + */ + fun getPinnedRecipientIds(): List { + return readableDatabase + .select(ID, RECIPIENT_ID) + .from(TABLE_NAME) + .where("$PINNED > 0") + .run() + .readToList { cursor -> + RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + } + } + + /** + * @return Pinned thread ids, in order from top to bottom. + */ + fun getPinnedThreadIds(): List { + return readableDatabase + .select(ID) + .from(TABLE_NAME) + .where("$PINNED > 0") + .run() + .readToList { cursor -> + cursor.requireLong(ID) + } + } + + fun restorePins(threadIds: Collection) { + Log.d(TAG, "Restoring pinned threads " + StringUtil.join(threadIds, ",")) + pinConversations(threadIds, true) + } + + fun pinConversations(threadIds: Collection) { + Log.d(TAG, "Pinning threads " + StringUtil.join(threadIds, ",")) + pinConversations(threadIds, false) + } + + private fun pinConversations(threadIds: Collection, clearFirst: Boolean) { + writableDatabase.withinTransaction { db -> + if (clearFirst) { + db.update(TABLE_NAME) + .values(PINNED to 0) + .where("$PINNED > 0") + .run() + } + + var pinnedCount = getPinnedConversationListCount() + + for (threadId in threadIds) { + pinnedCount++ + db.update(TABLE_NAME) + .values(PINNED to pinnedCount) + .where("$ID = ?", threadId) + .run() + } + } + + notifyConversationListListeners() + recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + } + + fun unpinConversations(threadIds: Collection) { + writableDatabase.withinTransaction { db -> + val query: SqlUtil.Query = SqlUtil.buildSingleCollectionQuery(ID, threadIds) + db.update(TABLE_NAME) + .values(PINNED to 0) + .where(query.where, *query.whereArgs) + .run() + + getPinnedThreadIds().forEachIndexed { index: Int, threadId: Long -> + db.update(TABLE_NAME) + .values(PINNED to index + 1) + .where("$ID = ?", threadId) + .run() + } + } + + notifyConversationListListeners() + recipients.markNeedsSync(Recipient.self().id) + StorageSyncHelper.scheduleSyncForDataChange() + } + + fun archiveConversation(threadId: Long) { + setArchived(setOf(threadId), archive = true) + } + + fun unarchiveConversation(threadId: Long) { + setArchived(setOf(threadId), archive = false) + } + + fun setLastSeen(threadId: Long) { + setLastSeenSilently(threadId) + notifyConversationListListeners() + } + + fun setLastSeenSilently(threadId: Long) { + writableDatabase + .update(TABLE_NAME) + .values(LAST_SEEN to System.currentTimeMillis()) + .where("$ID = ?", threadId) + .run() + } + + fun setLastScrolled(threadId: Long, lastScrolledTimestamp: Long) { + writableDatabase + .update(TABLE_NAME) + .values(LAST_SCROLLED to lastScrolledTimestamp) + .where("$ID = ?", threadId) + .run() + } + + fun getConversationMetadata(threadId: Long): ConversationMetadata { + return readableDatabase + .select(LAST_SEEN, HAS_SENT, LAST_SCROLLED) + .from(TABLE_NAME) + .where("$ID = ?", threadId) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + ConversationMetadata( + lastSeen = cursor.requireLong(LAST_SEEN), + hasSent = cursor.requireBoolean(HAS_SENT), + lastScrolled = cursor.requireLong(LAST_SCROLLED) + ) + } else { + ConversationMetadata( + lastSeen = -1L, + hasSent = false, + lastScrolled = -1 + ) + } + } + } + + fun deleteConversation(threadId: Long) { + val recipientIdForThreadId = getRecipientIdForThreadId(threadId) + + writableDatabase.withinTransaction { db -> + sms.deleteThread(threadId) + mms.deleteThread(threadId) + drafts.clearDrafts(threadId) + db.delete(TABLE_NAME) + .where("$ID = ?", threadId) + .run() + } + + notifyConversationListListeners() + notifyConversationListeners(threadId) + ConversationUtil.clearShortcuts(context, setOf(recipientIdForThreadId)) + } + + fun deleteConversations(selectedConversations: Set) { + val recipientIdsForThreadIds = getRecipientIdsForThreadIds(selectedConversations) + + writableDatabase.withinTransaction { db -> + sms.deleteThreads(selectedConversations) + mms.deleteThreads(selectedConversations) + drafts.clearDrafts(selectedConversations) + + SqlUtil.buildCollectionQuery(ID, selectedConversations) + .forEach { db.delete(TABLE_NAME, it.where, it.whereArgs) } + } + + notifyConversationListListeners() + notifyConversationListeners(selectedConversations) + ConversationUtil.clearShortcuts(context, recipientIdsForThreadIds) + } + + fun deleteAllConversations() { + writableDatabase.withinTransaction { db -> + messageLog.deleteAll() + sms.deleteAllThreads() + mms.deleteAllThreads() + drafts.clearAllDrafts() + db.delete(TABLE_NAME, null, null) + } + + notifyConversationListListeners() + ConversationUtil.clearAllShortcuts(context) + } + + fun getThreadIdIfExistsFor(recipientId: RecipientId): Long { + return readableDatabase + .select(ID) + .from(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + .use { cursor -> + return if (cursor.moveToFirst()) { + cursor.requireLong(ID) + } else { + -1 + } + } + } + + fun getOrCreateValidThreadId(recipient: Recipient, candidateId: Long): Long { + return getOrCreateValidThreadId(recipient, candidateId, DistributionTypes.DEFAULT) + } + + fun getOrCreateValidThreadId(recipient: Recipient, candidateId: Long, distributionType: Int): Long { + return if (candidateId != -1L) { + val remapped = RemappedRecords.getInstance().getThread(candidateId) + if (remapped.isPresent) { + Log.i(TAG, "Using remapped threadId: " + candidateId + " -> " + remapped.get()) + remapped.get() + } else { + candidateId + } + } else { + getOrCreateThreadIdFor(recipient, distributionType) + } + } + + fun getOrCreateThreadIdFor(recipient: Recipient): Long { + return getOrCreateThreadIdFor(recipient, DistributionTypes.DEFAULT) + } + + fun getOrCreateThreadIdFor(recipient: Recipient, distributionType: Int): Long { + val threadId = getThreadIdFor(recipient.id) + return threadId ?: createThreadForRecipient(recipient.id, recipient.isGroup, distributionType) + } + + fun getThreadIdFor(recipientId: RecipientId): Long? { + return readableDatabase + .select(ID) + .from(TABLE_NAME) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + cursor.requireLong(ID) + } else { + null + } + } + } + + private fun getRecipientIdForThreadId(threadId: Long): RecipientId? { + return readableDatabase + .select(RECIPIENT_ID) + .from(TABLE_NAME) + .where("$ID = ?", threadId) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + return RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + } else { + null + } + } + } + + fun getRecipientForThreadId(threadId: Long): Recipient? { + val id: RecipientId = getRecipientIdForThreadId(threadId) ?: return null + return Recipient.resolved(id) + } + + fun getRecipientIdsForThreadIds(threadIds: Collection): List { + val query = SqlUtil.buildSingleCollectionQuery(ID, threadIds) + + return readableDatabase + .select(RECIPIENT_ID) + .from(TABLE_NAME) + .where(query.where, *query.whereArgs) + .run() + .readToList { cursor -> + RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + } + } + + fun hasThread(recipientId: RecipientId): Boolean { + return getThreadIdIfExistsFor(recipientId) > -1 + } + + fun updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId: Long) { + writableDatabase + .update(TABLE_NAME) + .values( + LAST_SEEN to System.currentTimeMillis(), + HAS_SENT to 1, + LAST_SCROLLED to 0 + ) + .where("$ID = ?", threadId) + .run() + } + + fun setHasSentSilently(threadId: Long, hasSent: Boolean) { + writableDatabase + .update(TABLE_NAME) + .values(HAS_SENT to if (hasSent) 1 else 0) + .where("$ID = ?", threadId) + .run() + } + + fun updateReadState(threadId: Long) { + val previous = getThreadRecord(threadId) + val unreadCount = mmsSms.getUnreadCount(threadId) + val unreadMentionsCount = mms.getUnreadMentionCount(threadId) + + writableDatabase + .update(TABLE_NAME) + .values( + READ to if (unreadCount == 0) ReadStatus.READ.serialize() else ReadStatus.UNREAD.serialize(), + UNREAD_COUNT to unreadCount, + UNREAD_SELF_MENTION_COUNT to unreadMentionsCount + ) + .where("$ID = ?", threadId) + .run() + + notifyConversationListListeners() + + if (previous != null && previous.isForcedUnread) { + recipients.markNeedsSync(previous.recipient.id) + StorageSyncHelper.scheduleSyncForDataChange() + } + } + + fun applyStorageSyncUpdate(recipientId: RecipientId, record: SignalContactRecord) { + applyStorageSyncUpdate(recipientId, record.isArchived, record.isForcedUnread) + } + + fun applyStorageSyncUpdate(recipientId: RecipientId, record: SignalGroupV1Record) { + applyStorageSyncUpdate(recipientId, record.isArchived, record.isForcedUnread) + } + + fun applyStorageSyncUpdate(recipientId: RecipientId, record: SignalGroupV2Record) { + applyStorageSyncUpdate(recipientId, record.isArchived, record.isForcedUnread) + } + + fun applyStorageSyncUpdate(recipientId: RecipientId, record: SignalAccountRecord) { + writableDatabase.withinTransaction { db -> + applyStorageSyncUpdate(recipientId, record.isNoteToSelfArchived, record.isNoteToSelfForcedUnread) + + db.update(TABLE_NAME) + .values(PINNED to 0) + .run() + + var pinnedPosition = 1 + + for (pinned: PinnedConversation in record.pinnedConversations) { + val pinnedRecipient: Recipient? = if (pinned.contact.isPresent) { + Recipient.externalPush(pinned.contact.get()) + } else if (pinned.groupV1Id.isPresent) { + try { + Recipient.externalGroupExact(GroupId.v1(pinned.groupV1Id.get())) + } catch (e: BadGroupIdException) { + Log.w(TAG, "Failed to parse pinned groupV1 ID!", e) + null + } + } else if (pinned.groupV2MasterKey.isPresent) { + try { + Recipient.externalGroupExact(GroupId.v2(GroupMasterKey(pinned.groupV2MasterKey.get()))) + } catch (e: InvalidInputException) { + Log.w(TAG, "Failed to parse pinned groupV2 master key!", e) + null + } + } else { + Log.w(TAG, "Empty pinned conversation on the AccountRecord?") + null + } + + if (pinnedRecipient != null) { + db.update(TABLE_NAME) + .values(PINNED to pinnedPosition) + .where("$RECIPIENT_ID = ?", pinnedRecipient.id) + .run() + } + + pinnedPosition++ + } + } + + notifyConversationListListeners() + } + + private fun applyStorageSyncUpdate(recipientId: RecipientId, archived: Boolean, forcedUnread: Boolean) { + val values = ContentValues() + values.put(ARCHIVED, if (archived) 1 else 0) + + val threadId: Long? = getThreadIdFor(recipientId) + + if (forcedUnread) { + values.put(READ, ReadStatus.FORCED_UNREAD.serialize()) + } else if (threadId != null) { + val unreadCount = mmsSms.getUnreadCount(threadId) + val unreadMentionsCount = mms.getUnreadMentionCount(threadId) + + values.put(READ, if (unreadCount == 0) ReadStatus.READ.serialize() else ReadStatus.UNREAD.serialize()) + values.put(UNREAD_COUNT, unreadCount) + values.put(UNREAD_SELF_MENTION_COUNT, unreadMentionsCount) + } + + writableDatabase + .update(TABLE_NAME) + .values(values) + .where("$RECIPIENT_ID = ?", recipientId) + .run() + + if (threadId != null) { + notifyConversationListeners(threadId) + } + } + + fun update(threadId: Long, unarchive: Boolean): Boolean { + return update( + threadId = threadId, + unarchive = unarchive, + allowDeletion = true, + notifyListeners = true + ) + } + + fun updateSilently(threadId: Long, unarchive: Boolean): Boolean { + return update( + threadId = threadId, + unarchive = unarchive, + allowDeletion = true, + notifyListeners = false + ) + } + + fun update(threadId: Long, unarchive: Boolean, allowDeletion: Boolean): Boolean { + return update( + threadId = threadId, + unarchive = unarchive, + allowDeletion = allowDeletion, + notifyListeners = true + ) + } + + private fun update(threadId: Long, unarchive: Boolean, allowDeletion: Boolean, notifyListeners: Boolean): Boolean { + val meaningfulMessages = mmsSms.hasMeaningfulMessage(threadId) + + val isPinned = getPinnedThreadIds().contains(threadId) + val shouldDelete = allowDeletion && !isPinned && !mms.containsStories(threadId) + + if (!meaningfulMessages) { + if (shouldDelete) { + Log.d(TAG, "Deleting thread $threadId because it has no meaningful messages.") + deleteConversation(threadId) + return true + } else if (!isPinned) { + return false + } + } + + val record: MessageRecord = try { + mmsSms.getConversationSnippet(threadId) + } catch (e: NoSuchMessageException) { + Log.w(TAG, "Failed to get a conversation snippet for thread $threadId") + + if (shouldDelete) { + deleteConversation(threadId) + } + + if (isPinned) { + updateThread( + threadId = threadId, + meaningfulMessages = meaningfulMessages, + body = null, + attachment = null, + contentType = null, + extra = null, + date = 0, + status = 0, + deliveryReceiptCount = 0, + type = 0, + unarchive = unarchive, + expiresIn = 0, + readReceiptCount = 0 + ) + } + + return true + } + + updateThread( + threadId = threadId, + meaningfulMessages = meaningfulMessages, + body = ThreadBodyUtil.getFormattedBodyFor(context, record), + attachment = getAttachmentUriFor(record), + contentType = getContentTypeFor(record), + extra = getExtrasFor(record), + date = record.timestamp, + status = record.deliveryStatus, + deliveryReceiptCount = record.deliveryReceiptCount, + type = record.type, + unarchive = unarchive, + expiresIn = record.expiresIn, + readReceiptCount = record.readReceiptCount + ) + + if (notifyListeners) { + notifyConversationListListeners() + } + + return false + } + + fun updateSnippetTypeSilently(threadId: Long) { + if (threadId == -1L) { + return + } + + val type: Long = try { + mmsSms.getConversationSnippetType(threadId) + } catch (e: NoSuchMessageException) { + Log.w(TAG, "Unable to find snippet message for thread $threadId") + return + } + + writableDatabase + .update(TABLE_NAME) + .values(SNIPPET_TYPE to type) + .where("$ID = ?", threadId) + .run() + } + + fun getThreadRecordFor(recipient: Recipient): ThreadRecord { + return getThreadRecord(getOrCreateThreadIdFor(recipient))!! + } + + fun getAllThreadRecipients(): Set { + return readableDatabase + .select(RECIPIENT_ID) + .from(TABLE_NAME) + .run() + .readToList { cursor -> + RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + } + .toSet() + } + + fun merge(primaryRecipientId: RecipientId, secondaryRecipientId: RecipientId): MergeResult { + check(databaseHelper.signalWritableDatabase.inTransaction()) { "Must be in a transaction!" } + Log.w(TAG, "Merging threads. Primary: $primaryRecipientId, Secondary: $secondaryRecipientId", true) + + val primary: ThreadRecord? = getThreadRecord(getThreadIdFor(primaryRecipientId)) + val secondary: ThreadRecord? = getThreadRecord(getThreadIdFor(secondaryRecipientId)) + + return if (primary != null && secondary == null) { + Log.w(TAG, "[merge] Only had a thread for primary. Returning that.", true) + MergeResult(threadId = primary.threadId, previousThreadId = -1, neededMerge = false) + } else if (primary == null && secondary != null) { + Log.w(TAG, "[merge] Only had a thread for secondary. Updating it to have the recipientId of the primary.", true) + writableDatabase + .update(TABLE_NAME) + .values(RECIPIENT_ID to primaryRecipientId.serialize()) + .where("$ID = ?", secondary.threadId) + .run() + MergeResult(threadId = secondary.threadId, previousThreadId = -1, neededMerge = false) + } else if (primary == null && secondary == null) { + Log.w(TAG, "[merge] No thread for either.") + MergeResult(threadId = -1, previousThreadId = -1, neededMerge = false) + } else { + Log.w(TAG, "[merge] Had a thread for both. Deleting the secondary and merging the attributes together.", true) + check(primary != null) + check(secondary != null) + + writableDatabase + .delete(TABLE_NAME) + .where("$ID = ?", secondary.threadId) + .run() + + if (primary.expiresIn != secondary.expiresIn) { + val values = ContentValues() + if (primary.expiresIn == 0L) { + values.put(EXPIRES_IN, secondary.expiresIn) + } else if (secondary.expiresIn == 0L) { + values.put(EXPIRES_IN, primary.expiresIn) + } else { + values.put(EXPIRES_IN, min(primary.expiresIn, secondary.expiresIn)) + } + + writableDatabase + .update(TABLE_NAME) + .values(values) + .where("$ID = ?", primary.threadId) + .run() + } + + RemappedRecords.getInstance().addThread(secondary.threadId, primary.threadId) + + MergeResult(threadId = primary.threadId, previousThreadId = secondary.threadId, neededMerge = true) + } + } + + fun getThreadRecord(threadId: Long?): ThreadRecord? { + if (threadId == null) { + return null + } + + val query = createQuery("$TABLE_NAME.$ID = ?", 1) + + return readableDatabase.rawQuery(query, SqlUtil.buildArgs(threadId)).use { cursor -> + if (cursor.moveToFirst()) { + readerFor(cursor).getCurrent() + } else { + null + } + } + } + + private fun getAttachmentUriFor(record: MessageRecord): Uri? { + if (!record.isMms || record.isMmsNotification || record.isGroupAction) { + return null + } + + val slideDeck: SlideDeck = (record as MediaMmsMessageRecord).slideDeck + val thumbnail = Optional.ofNullable(slideDeck.thumbnailSlide) + .or(Optional.ofNullable(slideDeck.stickerSlide)) + .orElse(null) + + return if (thumbnail != null && !(record as MmsMessageRecord).isViewOnce) { + thumbnail.uri + } else { + null + } + } + + private fun getContentTypeFor(record: MessageRecord): String? { + if (record.isMms) { + val slideDeck = (record as MmsMessageRecord).slideDeck + if (slideDeck.slides.isNotEmpty()) { + return slideDeck.slides[0].contentType + } + } + return null + } + + private fun getExtrasFor(record: MessageRecord): Extra? { + val threadRecipient = if (record.isOutgoing) record.recipient else getRecipientForThreadId(record.threadId) + val messageRequestAccepted = RecipientUtil.isMessageRequestAccepted(record.threadId, threadRecipient) + val individualRecipientId = record.individualRecipient.id + + if (!messageRequestAccepted && threadRecipient != null) { + if (threadRecipient.isPushGroup) { + if (threadRecipient.isPushV2Group) { + val inviteAddState = record.gv2AddInviteState + if (inviteAddState != null) { + val from = RecipientId.from(ServiceId.from(inviteAddState.addedOrInvitedBy)) + return if (inviteAddState.isInvited) { + Log.i(TAG, "GV2 invite message request from $from") + Extra.forGroupV2invite(from, individualRecipientId) + } else { + Log.i(TAG, "GV2 message request from $from") + Extra.forGroupMessageRequest(from, individualRecipientId) + } + } + + Log.w(TAG, "Falling back to unknown message request state for GV2 message") + return Extra.forMessageRequest(individualRecipientId) + } else { + val recipientId = mmsSms.getGroupAddedBy(record.threadId) + if (recipientId != null) { + return Extra.forGroupMessageRequest(recipientId, individualRecipientId) + } + } + } else { + return Extra.forMessageRequest(individualRecipientId) + } + } + + return if (record.isRemoteDelete) { + Extra.forRemoteDelete(individualRecipientId) + } else if (record.isViewOnce) { + Extra.forViewOnce(individualRecipientId) + } else if (record.isMms && (record as MmsMessageRecord).slideDeck.stickerSlide != null) { + val slide: StickerSlide = record.slideDeck.stickerSlide!! + Extra.forSticker(slide.emoji, individualRecipientId) + } else if (record.isMms && (record as MmsMessageRecord).slideDeck.slides.size > 1) { + Extra.forAlbum(individualRecipientId) + } else if (threadRecipient != null && threadRecipient.isGroup) { + Extra.forDefault(individualRecipientId) + } else { + null + } + } + + private fun createQuery(where: String, limit: Long): String { + return createQuery( + where = where, + offset = 0, + limit = limit, + preferPinned = false + ) + } + + private fun createQuery(where: String, offset: Long, limit: Long, preferPinned: Boolean): String { + val orderBy = if (preferPinned) { + "$TABLE_NAME.$PINNED DESC, $TABLE_NAME.$DATE DESC" + } else { + "$TABLE_NAME.$DATE DESC" + } + + return createQuery( + where = where, + orderBy = orderBy, + offset = offset, + limit = limit + ) + } + + private fun createQuery(where: String, orderBy: String, offset: Long, limit: Long): String { + val projection = COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION.joinToString(separator = ",") + + var query = """ + SELECT $projection + FROM $TABLE_NAME + LEFT OUTER JOIN ${RecipientDatabase.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientDatabase.TABLE_NAME}.${RecipientDatabase.ID} + LEFT OUTER JOIN ${GroupDatabase.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${GroupDatabase.TABLE_NAME}.${GroupDatabase.RECIPIENT_ID} + WHERE $where + ORDER BY $orderBy + """.trimIndent() + + if (limit > 0) { + query += " LIMIT $limit" + } + + if (offset > 0) { + query += " OFFSET $offset" + } + + return query.toSingleLine() + } + + private fun isSilentType(type: Long): Boolean { + return MmsSmsColumns.Types.isProfileChange(type) || + MmsSmsColumns.Types.isGroupV1MigrationEvent(type) || + MmsSmsColumns.Types.isChangeNumber(type) || + MmsSmsColumns.Types.isBoostRequest(type) || + MmsSmsColumns.Types.isGroupV2LeaveOnly(type) || + MmsSmsColumns.Types.isThreadMergeType(type) + } + + fun readerFor(cursor: Cursor): Reader { + return Reader(cursor) + } + + private fun String.toSingleLine(): String { + return this.trimIndent().split("\n").joinToString(separator = " ") + } + + object DistributionTypes { + const val DEFAULT = 2 + const val BROADCAST = 1 + const val CONVERSATION = 2 + const val ARCHIVE = 3 + const val INBOX_ZERO = 4 + } + + inner class Reader(cursor: Cursor) : StaticReader(cursor, context) + + open class StaticReader(private val cursor: Cursor, private val context: Context) : Closeable { + fun getNext(): ThreadRecord? { + return if (!cursor.moveToNext()) { + null + } else { + getCurrent() + } + } + + open fun getCurrent(): ThreadRecord? { + val recipientId = RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + val recipientSettings = recipients.getRecord(context, cursor, RECIPIENT_ID) + + val recipient: Recipient = if (recipientSettings.groupId != null) { + GroupDatabase.Reader(cursor).current?.let { group -> + val details = RecipientDetails( + group.title, + null, + if (group.hasAvatar()) Optional.of(group.avatarId) else Optional.empty(), + false, + false, + recipientSettings.registered, + recipientSettings, + null, + false + ) + Recipient(recipientId, details, false) + } ?: Recipient.live(recipientId).get() + } else { + val details = RecipientDetails.forIndividual(context, recipientSettings) + Recipient(recipientId, details, true) + } + + val readReceiptCount = if (TextSecurePreferences.isReadReceiptsEnabled(context)) cursor.requireInt(READ_RECEIPT_COUNT) else 0 + val extraString = cursor.getString(cursor.getColumnIndexOrThrow(SNIPPET_EXTRAS)) + val extra: Extra? = if (extraString != null) { + try { + JsonUtils.fromJson(extraString, Extra::class.java) + } catch (e: IOException) { + Log.w(TAG, "Failed to decode extras!") + null + } + } else { + null + } + + return ThreadRecord.Builder(cursor.requireLong(ID)) + .setRecipient(recipient) + .setType(cursor.requireInt(SNIPPET_TYPE).toLong()) + .setDistributionType(cursor.requireInt(TYPE)) + .setBody(cursor.requireString(SNIPPET) ?: "") + .setDate(cursor.requireLong(DATE)) + .setArchived(cursor.requireBoolean(ARCHIVED)) + .setDeliveryStatus(cursor.requireInt(STATUS).toLong()) + .setDeliveryReceiptCount(cursor.requireInt(DELIVERY_RECEIPT_COUNT)) + .setReadReceiptCount(readReceiptCount) + .setExpiresIn(cursor.requireLong(EXPIRES_IN)) + .setLastSeen(cursor.requireLong(LAST_SEEN)) + .setSnippetUri(getSnippetUri(cursor)) + .setContentType(cursor.requireString(SNIPPET_CONTENT_TYPE)) + .setMeaningfulMessages(cursor.requireLong(MEANINGFUL_MESSAGES) > 0) + .setUnreadCount(cursor.requireInt(UNREAD_COUNT)) + .setForcedUnread(cursor.requireInt(READ) == ReadStatus.FORCED_UNREAD.serialize()) + .setPinned(cursor.requireBoolean(PINNED)) + .setUnreadSelfMentionsCount(cursor.requireInt(UNREAD_SELF_MENTION_COUNT)) + .setExtra(extra) + .build() + } + + private fun getSnippetUri(cursor: Cursor?): Uri? { + return if (cursor!!.isNull(cursor.getColumnIndexOrThrow(SNIPPET_URI))) { + null + } else try { + Uri.parse(cursor.getString(cursor.getColumnIndexOrThrow(SNIPPET_URI))) + } catch (e: IllegalArgumentException) { + Log.w(TAG, e) + null + } + } + + override fun close() { + cursor.close() + } + } + + 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 + ) { + + fun getIndividualRecipientId(): String { + return individualRecipientId + } + + 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()) + } + + 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()) + } + + 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()) + } + + 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()) + } + + 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()) + } + + 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()) + } + + 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()) + } + + 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()) + } + } + } + + internal enum class ReadStatus(private val value: Int) { + READ(1), UNREAD(0), FORCED_UNREAD(2); + + fun serialize(): Int { + return value + } + + companion object { + fun deserialize(value: Int): ReadStatus { + for (status in values()) { + if (status.value == value) { + return status + } + } + throw IllegalArgumentException("No matching status for value $value") + } + } + } + + data class ConversationMetadata( + val lastSeen: Long, + @get:JvmName("hasSent") + val hasSent: Boolean, + val lastScrolled: Long + ) + + data class MergeResult(val threadId: Long, val previousThreadId: Long, val neededMerge: Boolean) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt index eb04c817d8..8792e600df 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/tabs/ConversationListTabRepository.kt @@ -17,9 +17,9 @@ class ConversationListTabRepository { fun getNumberOfUnreadConversations(): Observable { return Observable.create { fun refresh() { - it.onNext(SignalDatabase.threads.unreadThreadCount) + it.onNext(SignalDatabase.threads.getUnreadThreadCount()) - val ids = SignalDatabase.threads.unreadThreadIdList + val ids = SignalDatabase.threads.getUnreadThreadIdList() Log.d(TAG, "Unread threads: { $ids }") } diff --git a/core-util/src/main/java/org/signal/core/util/CursorUtil.java b/core-util/src/main/java/org/signal/core/util/CursorUtil.java index 912351fa86..3336114123 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorUtil.java +++ b/core-util/src/main/java/org/signal/core/util/CursorUtil.java @@ -104,12 +104,4 @@ public final class CursorUtil { return row.toString(); } - - public static @Nullable T getAggregateOrDefault(@NonNull Cursor cursor, @Nullable T defaultValue, @NonNull Function cursorColumnFn) { - if (cursor.moveToFirst()) { - return cursorColumnFn.apply(0); - } else { - return defaultValue; - } - } }