From 906441c90ca27552ee2c1456a38f7afa4b3ab96e Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 27 Oct 2022 16:36:12 -0400 Subject: [PATCH] Revert "Convert ThreadDatabase to kotlin." This reverts commit 1e88fb428db7b5281e5a2790272eedd525f519eb. --- .../database/ThreadDatabaseTest_pinned.kt | 4 +- .../securesms/database/RecipientDatabase.kt | 4 +- .../securesms/database/ThreadDatabase.java | 1950 +++++++++++++++++ .../securesms/database/ThreadDatabase.kt | 1759 --------------- .../tabs/ConversationListTabRepository.kt | 4 +- .../java/org/signal/core/util/CursorUtil.java | 8 + 6 files changed, 1964 insertions(+), 1765 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java delete 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 e3f9b8300..7ba8a53d5 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.getPinnedThreadIds() + val pinned = SignalDatabase.threads.pinnedThreadIds assertTrue(threadId in pinned) } @@ -51,7 +51,7 @@ class ThreadDatabaseTest_pinned { SignalDatabase.mms.deleteMessage(messageId) // THEN - val unarchivedCount = SignalDatabase.threads.getUnarchivedConversationListCount() + val unarchivedCount = SignalDatabase.threads.unarchivedConversationListCount 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 1d9e5e986..1feed7341 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt @@ -3162,7 +3162,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : val recipientsWithinInteractionThreshold: MutableSet = LinkedHashSet() threadDatabase.readerFor(threadDatabase.getRecentPushConversationList(-1, false)).use { reader -> - var record: ThreadRecord? = reader.getNext() + var record: ThreadRecord? = reader.next while (record != null && record.date > lastInteractionThreshold) { val recipient = Recipient.resolved(record.recipient.id) @@ -3171,7 +3171,7 @@ open class RecipientDatabase(context: Context, databaseHelper: SignalDatabase) : } else { recipientsWithinInteractionThreshold.add(recipient.id) } - record = reader.getNext() + record = reader.next } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java new file mode 100644 index 000000000..1a9512e27 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -0,0 +1,1950 @@ +/* + * 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 deleted file mode 100644 index 8dacea22a..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.kt +++ /dev/null @@ -1,1759 +0,0 @@ -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 = ? - """.trimIndent(), - 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} - ) - )""".trimMargin() - 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 - ) - ) - """.trimIndent() - - 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) - .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 - } - - 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) - } - - 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 8792e600d..eb04c817d 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.getUnreadThreadCount()) + it.onNext(SignalDatabase.threads.unreadThreadCount) - val ids = SignalDatabase.threads.getUnreadThreadIdList() + val ids = SignalDatabase.threads.unreadThreadIdList 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 333611412..912351fa8 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,4 +104,12 @@ 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; + } + } }