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.exists import org.signal.core.util.logging.Log import org.signal.core.util.or import org.signal.core.util.readToList import org.signal.core.util.readToSingleLong 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.toSingleLine 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.conversationlist.model.ConversationFilter import org.thoughtcrime.securesms.database.MessageTable.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.messages import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients 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 ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) { companion object { private val TAG = Log.tag(ThreadTable::class.java) const val TABLE_NAME = "thread" const val ID = "_id" const val DATE = "date" const val MEANINGFUL_MESSAGES = "meaningful_messages" const val RECIPIENT_ID = "recipient_id" const val READ = "read" const val UNREAD_COUNT = "unread_count" const val TYPE = "type" const val ERROR = "error" const val SNIPPET = "snippet" 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 NOT NULL UNIQUE REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, $READ INTEGER DEFAULT ${ReadStatus.READ.serialize()}, $TYPE INTEGER DEFAULT 0, $ERROR INTEGER DEFAULT 0, $SNIPPET TEXT, $SNIPPET_TYPE INTEGER DEFAULT 0, $SNIPPET_URI TEXT DEFAULT NULL, $SNIPPET_CONTENT_TYPE TEXT DEFAULT NULL, $SNIPPET_EXTRAS TEXT DEFAULT NULL, $UNREAD_COUNT INTEGER DEFAULT 0, $ARCHIVED INTEGER DEFAULT 0, $STATUS INTEGER DEFAULT 0, $DELIVERY_RECEIPT_COUNT INTEGER DEFAULT 0, $READ_RECEIPT_COUNT INTEGER DEFAULT 0, $EXPIRES_IN INTEGER DEFAULT 0, $LAST_SEEN INTEGER DEFAULT 0, $HAS_SENT 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);", "CREATE INDEX IF NOT EXISTS thread_read ON $TABLE_NAME ($READ);" ) private val THREAD_PROJECTION = arrayOf( ID, DATE, MEANINGFUL_MESSAGES, RECIPIENT_ID, SNIPPET, 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 + RecipientTable.TYPED_RECIPIENT_PROJECTION_NO_ID + GroupTable.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 { messages.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) messages.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) { messages.getConversation(threadId).use { cursor -> if (cursor.count > length) { cursor.moveToPosition(length - 1) max(trimBeforeDate, cursor.requireLong(MessageTable.DATE_RECEIVED)) } else { trimBeforeDate } } } else { trimBeforeDate } if (finalTrimBeforeDate != NO_TRIM_BEFORE_DATE_SET) { Log.i(TAG, "Trimming thread: $threadId before: $finalTrimBeforeDate") val deletes = messages.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 messageRecords: List = messages.setAllMessagesRead() messages.setAllReactionsSeen() notifyConversationListListeners() return messageRecords } fun hasCalledSince(recipient: Recipient, timestamp: Long): Boolean { return hasReceivedAnyCallsSince(getOrCreateThreadIdFor(recipient), timestamp) } fun hasReceivedAnyCallsSince(threadId: Long, timestamp: Long): Boolean { return messages.hasReceivedAnyCallsSince(threadId, timestamp) } fun setEntireThreadRead(threadId: Long): List { setRead(threadId, false) return messages.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 messages.setGroupStoryMessagesReadSince(threadId, groupStoryId, sinceTimestamp) } fun setReadSince(threadIdToSinceTimestamp: Map, lastSeen: Boolean): List { val messageRecords: MutableList = LinkedList() var needsSync = false writableDatabase.withinTransaction { db -> for ((threadId, sinceTimestamp) in threadIdToSinceTimestamp) { val previous = getThreadRecord(threadId) messageRecords += messages.setMessagesReadSince(threadId, sinceTimestamp) messages.setReactionsSeen(threadId, sinceTimestamp) val unreadCount = messages.getUnreadCount(threadId) val unreadMentionsCount = messages.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 messageRecords } 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 } } } /** * Returns the number of unread messages across all threads. * Threads that are forced-unread count as 1. */ fun getUnreadMessageCount(): Long { val allCount: Long = readableDatabase .select("SUM($UNREAD_COUNT)") .from(TABLE_NAME) .where("$ARCHIVED = ?", 0) .run() .use { cursor -> if (cursor.moveToFirst()) { cursor.getLong(0) } else { 0 } } val forcedUnreadCount: Long = readableDatabase .select("COUNT(*)") .from(TABLE_NAME) .where("$READ = ? AND $ARCHIVED = ?", ReadStatus.FORCED_UNREAD.serialize(), 0) .run() .use { cursor -> if (cursor.moveToFirst()) { cursor.getLong(0) } else { 0 } } return allCount + forcedUnreadCount } /** * Returns the number of unread messages in a given thread. */ fun getUnreadMessageCount(threadId: Long): Long { return readableDatabase .select(UNREAD_COUNT) .from(TABLE_NAME) .where("$ID = ?", threadId) .run() .use { cursor -> if (cursor.moveToFirst()) { CursorUtil.requireLong(cursor, UNREAD_COUNT) } else { 0L } } } fun getUnreadThreadIdList(): String? { return getUnreadThreadIdAggregate(arrayOf("GROUP_CONCAT($ID)")) { cursor -> if (cursor.moveToFirst()) { cursor.getString(0) } else { null } } } private fun getUnreadThreadIdAggregate(aggregator: Array, mapCursorToType: (Cursor) -> T): T { return readableDatabase .select(*aggregator) .from(TABLE_NAME) .where("$READ != ${ReadStatus.READ.serialize()} AND $ARCHIVED = 0 AND $MEANINGFUL_MESSAGES != 0") .run() .use(mapCursorToType) } fun incrementUnread(threadId: Long, unreadAmount: Int, unreadSelfMentionAmount: Int) { writableDatabase.execSQL( """ UPDATE $TABLE_NAME SET $READ = ${ReadStatus.UNREAD.serialize()}, $UNREAD_COUNT = $UNREAD_COUNT + ?, $UNREAD_SELF_MENTION_COUNT = $UNREAD_SELF_MENTION_COUNT + ?, $LAST_SCROLLED = ? WHERE $ID = ? """.toSingleLine(), SqlUtil.buildArgs(unreadAmount, unreadSelfMentionAmount, 0, threadId) ) } fun setDistributionType(threadId: Long, distributionType: Int) { writableDatabase .update(TABLE_NAME) .values(TYPE to distributionType) .where("$ID = ?", threadId) .run() notifyConversationListListeners() } fun getDistributionType(threadId: Long): Int { return readableDatabase .select(TYPE) .from(TABLE_NAME) .where("$ID = ?", threadId) .run() .use { cursor -> if (cursor.moveToFirst()) { cursor.requireInt(TYPE) } else { DistributionTypes.DEFAULT } } } fun getFilteredConversationList(filter: List, unreadOnly: Boolean): 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++ } selection += if (unreadOnly) { ") AND $TABLE_NAME.$READ != ${ReadStatus.READ.serialize()}" } else { ")" } 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 (${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} IS NULL OR ${GroupTable.TABLE_NAME}.${GroupTable.ACTIVE} = 1)" } else { where += "$MEANINGFUL_MESSAGES != 0" } if (groupsOnly) { where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.GROUP_ID} NOT NULL" } if (individualsOnly) { where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.GROUP_ID} IS NULL" } if (hideV1Groups) { where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.GROUP_TYPE} != ${RecipientTable.GroupType.SIGNAL_V1.id}" } if (hideSms) { where += """ AND ( ${RecipientTable.TABLE_NAME}.${RecipientTable.REGISTERED} = ${RecipientTable.RegisteredState.REGISTERED.id} OR ( ${RecipientTable.TABLE_NAME}.${RecipientTable.GROUP_ID} NOT NULL AND ${RecipientTable.TABLE_NAME}.${RecipientTable.GROUP_TYPE} != ${RecipientTable.GroupType.MMS.id} ) )""" where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.FORCE_SMS_SELECTION} = 0" } if (hideSelf) { where += " AND $TABLE_NAME.$RECIPIENT_ID != ${Recipient.self().id.toLong()}" } where += " AND $ARCHIVED = 0" where += " AND ${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = 0" if (SignalStore.releaseChannelValues().releaseChannelRecipientId != null) { where += " AND $TABLE_NAME.$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 " + GroupTable.TABLE_NAME + "." + GroupTable.ACTIVE + " = 1" else "" val where = """ $MEANINGFUL_MESSAGES != 0 AND ( ${RecipientTable.REGISTERED} = ${RecipientTable.RegisteredState.REGISTERED.id} OR ( ${GroupTable.TABLE_NAME}.${GroupTable.GROUP_ID} NOT NULL AND ${GroupTable.TABLE_NAME}.${GroupTable.MMS} = 0 $activeGroupQuery ) ) """ val query = createQuery( where = where, offset = 0, limit = limit.toLong(), preferPinned = true ) return readableDatabase.rawQuery(query, null) } fun isArchived(recipientId: RecipientId): Boolean { return readableDatabase .select(ARCHIVED) .from(TABLE_NAME) .where("$RECIPIENT_ID = ?", recipientId) .run() .use { cursor -> if (cursor.moveToFirst()) { cursor.requireBoolean(ARCHIVED) } else { false } } } fun setArchived(threadIds: Set, archive: Boolean) { var recipientIds: List = emptyList() writableDatabase.withinTransaction { db -> for (threadId in threadIds) { val values = ContentValues().apply { if (archive) { put(PINNED, "0") put(ARCHIVED, "1") } else { put(ARCHIVED, "0") } } db.update(TABLE_NAME) .values(values) .where("$ID = ?", threadId) .run() } recipientIds = getRecipientIdsForThreadIds(threadIds) recipients.markNeedsSyncWithoutRefresh(recipientIds) } for (id in recipientIds) { Recipient.live(id).refresh() } notifyConversationListListeners() StorageSyncHelper.scheduleSyncForDataChange() } fun getArchivedRecipients(): Set { return getArchivedConversationList(ConversationFilter.OFF).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(conversationFilter: ConversationFilter, offset: Long = 0, limit: Long = 0): Cursor { val filterQuery = conversationFilter.toQuery() val query = createQuery("$ARCHIVED = ? AND $MEANINGFUL_MESSAGES != 0 $filterQuery", offset, limit, preferPinned = false) return readableDatabase.rawQuery(query, arrayOf("1")) } fun getUnarchivedConversationList(conversationFilter: ConversationFilter, pinned: Boolean, offset: Long, limit: Long): Cursor { val filterQuery = conversationFilter.toQuery() val where = if (pinned) { "$ARCHIVED = 0 AND $PINNED != 0 $filterQuery" } else { "$ARCHIVED = 0 AND $PINNED = 0 AND $MEANINGFUL_MESSAGES != 0 $filterQuery" } val query = if (pinned) { createQuery(where, PINNED + " ASC", offset, limit) } else { createQuery(where, offset, limit, preferPinned = false) } return readableDatabase.rawQuery(query, null) } fun getArchivedConversationListCount(conversationFilter: ConversationFilter): Int { val filterQuery = conversationFilter.toQuery() return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) .where("$ARCHIVED = 1 AND $MEANINGFUL_MESSAGES != 0 $filterQuery") .run() .use { cursor -> if (cursor.moveToFirst()) { cursor.getInt(0) } else { 0 } } } fun getPinnedConversationListCount(conversationFilter: ConversationFilter): Int { val filterQuery = conversationFilter.toQuery() return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) .where("$ARCHIVED = 0 AND $PINNED != 0 $filterQuery") .run() .use { cursor -> if (cursor.moveToFirst()) { cursor.getInt(0) } else { 0 } } } fun getUnarchivedConversationListCount(conversationFilter: ConversationFilter): Int { val filterQuery = conversationFilter.toQuery() return readableDatabase .select("COUNT(*)") .from(TABLE_NAME) .where("$ARCHIVED = 0 AND ($MEANINGFUL_MESSAGES != 0 OR $PINNED != 0) $filterQuery") .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(ConversationFilter.OFF) 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 -> messages.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 -> messages.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() messages.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() .readToSingleLong(-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 { if (areThreadIdAndRecipientAssociated(candidateId, recipient)) { candidateId } else { throw IllegalArgumentException() } } } 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 areThreadIdAndRecipientAssociated(threadId: Long, recipient: Recipient): Boolean { return readableDatabase .exists(TABLE_NAME) .where("$ID = ? AND $RECIPIENT_ID = ?", threadId, recipient.id) .run() } 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 } } } 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 = messages.getUnreadCount(threadId) val unreadMentionsCount = messages.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 = messages.getUnreadCount(threadId) val unreadMentionsCount = messages.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 = messages.hasMeaningfulMessage(threadId) val isPinned = getPinnedThreadIds().contains(threadId) val shouldDelete = allowDeletion && !isPinned && !messages.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 { messages.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 } val drafts: DraftTable.Drafts = SignalDatabase.drafts.getDrafts(threadId) if (drafts.isNotEmpty()) { val threadRecord: ThreadRecord? = getThreadRecord(threadId) if (threadRecord != null && threadRecord.type == MessageTypes.BASE_DRAFT_TYPE && threadRecord.date > record.timestamp ) { return false } } 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 { messages.getConversationSnippetType(threadId) } catch (e: NoSuchMessageException) { Log.w(TAG, "Unable to find snippet message for thread $threadId") return } writableDatabase .update(TABLE_NAME) .values(SNIPPET_TYPE to type) .where("$ID = ?", threadId) .run() } fun getThreadRecordFor(recipient: Recipient): ThreadRecord { return getThreadRecord(getOrCreateThreadIdFor(recipient))!! } fun getAllThreadRecipients(): Set { return readableDatabase .select(RECIPIENT_ID) .from(TABLE_NAME) .run() .readToList { cursor -> RecipientId.from(cursor.requireLong(RECIPIENT_ID)) } .toSet() } fun merge(primaryRecipientId: RecipientId, secondaryRecipientId: RecipientId): MergeResult { check(databaseHelper.signalWritableDatabase.inTransaction()) { "Must be in a transaction!" } Log.w(TAG, "Merging threads. Primary: $primaryRecipientId, Secondary: $secondaryRecipientId", true) val primary: ThreadRecord? = getThreadRecord(getThreadIdFor(primaryRecipientId)) val secondary: ThreadRecord? = getThreadRecord(getThreadIdFor(secondaryRecipientId)) return if (primary != null && secondary == null) { Log.w(TAG, "[merge] Only had a thread for primary. Returning that.", true) MergeResult(threadId = primary.threadId, previousThreadId = -1, neededMerge = false) } else if (primary == null && secondary != null) { Log.w(TAG, "[merge] Only had a thread for secondary. Updating it to have the recipientId of the primary.", true) writableDatabase .update(TABLE_NAME) .values(RECIPIENT_ID to primaryRecipientId.serialize()) .where("$ID = ?", secondary.threadId) .run() MergeResult(threadId = secondary.threadId, previousThreadId = -1, neededMerge = false) } else if (primary == null && secondary == null) { Log.w(TAG, "[merge] No thread for either.") MergeResult(threadId = -1, previousThreadId = -1, neededMerge = false) } else { Log.w(TAG, "[merge] Had a thread for both. Deleting the secondary and merging the attributes together.", true) check(primary != null) check(secondary != null) for (table in threadIdDatabaseTables) { table.remapThread(secondary.threadId, primary.threadId) } 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 = messages.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 ${RecipientTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} LEFT OUTER JOIN ${GroupTable.TABLE_NAME} ON $TABLE_NAME.$RECIPIENT_ID = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID} WHERE $where ORDER BY $orderBy """.trimIndent() if (limit > 0) { query += " LIMIT $limit" } if (offset > 0) { query += " OFFSET $offset" } return query.toSingleLine() } private fun isSilentType(type: Long): Boolean { return MessageTypes.isProfileChange(type) || MessageTypes.isGroupV1MigrationEvent(type) || MessageTypes.isChangeNumber(type) || MessageTypes.isBoostRequest(type) || MessageTypes.isGroupV2LeaveOnly(type) || MessageTypes.isThreadMergeType(type) } fun readerFor(cursor: Cursor): Reader { return Reader(cursor) } private fun ConversationFilter.toQuery(): String { return when (this) { ConversationFilter.OFF -> "" //language=sql ConversationFilter.UNREAD -> " AND ($UNREAD_COUNT > 0 OR $READ == ${ReadStatus.FORCED_UNREAD.serialize()})" ConversationFilter.MUTED -> error("This filter selection isn't supported yet.") ConversationFilter.GROUPS -> error("This filter selection isn't supported yet.") } } 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) { GroupTable.Reader(cursor).getCurrent()?.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) }