diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsRepository.kt index 048f58f9d3..4a0e71b5fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/sms/SmsSettingsRepository.kt @@ -18,7 +18,7 @@ class SmsSettingsRepository( @WorkerThread private fun checkInsecureMessageCount(): SmsExportState? { - val totalSmsMmsCount = smsDatabase.insecureMessageCount + mmsDatabase.insecureMessageCount + val totalSmsMmsCount = smsDatabase.getInsecureMessageCount() + mmsDatabase.getInsecureMessageCount() return if (totalSmsMmsCount == 0) { SmsExportState.NO_SMS_MESSAGES_IN_DATABASE @@ -29,7 +29,7 @@ class SmsSettingsRepository( @WorkerThread private fun checkUnexportedInsecureMessageCount(): SmsExportState { - val totalUnexportedCount = smsDatabase.unexportedInsecureMessagesCount + mmsDatabase.unexportedInsecureMessagesCount + val totalUnexportedCount = smsDatabase.getUnexportedInsecureMessagesCount() + mmsDatabase.getUnexportedInsecureMessagesCount() return if (totalUnexportedCount > 0) { SmsExportState.HAS_UNEXPORTED_MESSAGES diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 7a4613c5b5..0c27dd525b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -1193,7 +1193,6 @@ public class ConversationFragment extends LoggingFragment implements Multiselect if (message.getScheduledDate() != -1) { return; } - MessageRecord messageRecord = MessageTable.readerFor(message, threadId).getCurrent(); if (getListAdapter() != null) { setLastSeen(0); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java deleted file mode 100644 index b7fb19aacf..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java +++ /dev/null @@ -1,5544 +0,0 @@ -/** - * Copyright (C) 2011 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.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.text.SpannableString; -import android.text.TextUtils; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; -import com.google.android.mms.pdu_alt.NotificationInd; -import com.google.android.mms.pdu_alt.PduHeaders; -import com.google.protobuf.ByteString; -import com.google.protobuf.InvalidProtocolBufferException; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; -import org.signal.core.util.CursorExtensionsKt; -import org.signal.core.util.CursorUtil; -import org.signal.core.util.SQLiteDatabaseExtensionsKt; -import org.signal.core.util.SqlUtil; -import org.signal.core.util.logging.Log; -import org.signal.libsignal.protocol.IdentityKey; -import org.signal.libsignal.protocol.util.Pair; -import org.thoughtcrime.securesms.attachments.Attachment; -import org.thoughtcrime.securesms.attachments.AttachmentId; -import org.thoughtcrime.securesms.attachments.DatabaseAttachment; -import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment; -import org.thoughtcrime.securesms.contactshare.Contact; -import org.thoughtcrime.securesms.conversation.MessageStyler; -import org.thoughtcrime.securesms.database.documents.Document; -import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; -import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet; -import org.thoughtcrime.securesms.database.documents.NetworkFailure; -import org.thoughtcrime.securesms.database.documents.NetworkFailureSet; -import org.thoughtcrime.securesms.database.model.DisplayRecord; -import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil; -import org.thoughtcrime.securesms.database.model.GroupRecord; -import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.Mention; -import org.thoughtcrime.securesms.database.model.MessageExportStatus; -import org.thoughtcrime.securesms.database.model.MessageId; -import org.thoughtcrime.securesms.database.model.MessageRecord; -import org.thoughtcrime.securesms.database.model.MmsMessageRecord; -import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord; -import org.thoughtcrime.securesms.database.model.ParentStoryId; -import org.thoughtcrime.securesms.database.model.Quote; -import org.thoughtcrime.securesms.database.model.StoryResult; -import org.thoughtcrime.securesms.database.model.StoryType; -import org.thoughtcrime.securesms.database.model.StoryViewState; -import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; -import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge; -import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails; -import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState; -import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails; -import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent; -import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; -import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange; -import org.thoughtcrime.securesms.insights.InsightsConstants; -import org.thoughtcrime.securesms.jobs.OptimizeMessageSearchIndexJob; -import org.thoughtcrime.securesms.jobs.TrimThreadJob; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.linkpreview.LinkPreview; -import org.thoughtcrime.securesms.mms.IncomingMediaMessage; -import org.thoughtcrime.securesms.mms.MessageGroupContext; -import org.thoughtcrime.securesms.mms.MmsException; -import org.thoughtcrime.securesms.mms.OutgoingMessage; -import org.thoughtcrime.securesms.mms.QuoteModel; -import org.thoughtcrime.securesms.mms.SlideDeck; -import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo; -import org.thoughtcrime.securesms.revealable.ViewOnceUtil; -import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage; -import org.thoughtcrime.securesms.sms.IncomingTextMessage; -import org.thoughtcrime.securesms.stories.Stories; -import org.thoughtcrime.securesms.util.Base64; -import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.JsonUtils; -import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Util; -import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage; -import org.whispersystems.signalservice.api.push.ServiceId; - -import java.io.Closeable; -import java.io.IOException; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.function.Function; - -import kotlin.Unit; - -import static org.thoughtcrime.securesms.contactshare.Contact.Avatar; - -public class MessageTable extends DatabaseTable implements MessageTypes, RecipientIdDatabaseReference, ThreadIdDatabaseReference { - - private static final String TAG = Log.tag(MessageTable.class); - - public static final String TABLE_NAME = "message"; - public static final String ID = "_id"; - public static final String DATE_SENT = "date_sent"; - public static final String DATE_RECEIVED = "date_received"; - public static final String TYPE = "type"; - public static final String DATE_SERVER = "date_server"; - public static final String THREAD_ID = "thread_id"; - public static final String READ = "read"; - public static final String BODY = "body"; - public static final String RECIPIENT_ID = "recipient_id"; - public static final String RECIPIENT_DEVICE_ID = "recipient_device_id"; - public static final String DELIVERY_RECEIPT_COUNT = "delivery_receipt_count"; - public static final String READ_RECEIPT_COUNT = "read_receipt_count"; - public static final String VIEWED_RECEIPT_COUNT = "viewed_receipt_count"; - public static final String MISMATCHED_IDENTITIES = "mismatched_identities"; - public static final String SMS_SUBSCRIPTION_ID = "subscription_id"; - public static final String EXPIRES_IN = "expires_in"; - public static final String EXPIRE_STARTED = "expire_started"; - public static final String NOTIFIED = "notified"; - public static final String NOTIFIED_TIMESTAMP = "notified_timestamp"; - public static final String UNIDENTIFIED = "unidentified"; - public static final String REACTIONS_UNREAD = "reactions_unread"; - public static final String REACTIONS_LAST_SEEN = "reactions_last_seen"; - public static final String REMOTE_DELETED = "remote_deleted"; - public static final String SERVER_GUID = "server_guid"; - public static final String RECEIPT_TIMESTAMP = "receipt_timestamp"; - public static final String EXPORT_STATE = "export_state"; - public static final String EXPORTED = "exported"; - public static final String MMS_CONTENT_LOCATION = "ct_l"; - public static final String MMS_EXPIRY = "exp"; - public static final String MMS_MESSAGE_TYPE = "m_type"; - public static final String MMS_MESSAGE_SIZE = "m_size"; - public static final String MMS_STATUS = "st"; - public static final String MMS_TRANSACTION_ID = "tr_id"; - public static final String NETWORK_FAILURES = "network_failures"; - public static final String QUOTE_ID = "quote_id"; - public static final String QUOTE_AUTHOR = "quote_author"; - public static final String QUOTE_BODY = "quote_body"; - public static final String QUOTE_MISSING = "quote_missing"; - public static final String QUOTE_BODY_RANGES = "quote_mentions"; - public static final String QUOTE_TYPE = "quote_type"; - public static final String SHARED_CONTACTS = "shared_contacts"; - public static final String LINK_PREVIEWS = "link_previews"; - public static final String MENTIONS_SELF = "mentions_self"; - public static final String MESSAGE_RANGES = "message_ranges"; - public static final String VIEW_ONCE = "view_once"; - public static final String STORY_TYPE = "story_type"; - public static final String PARENT_STORY_ID = "parent_story_id"; - public static final String SCHEDULED_DATE = "scheduled_date"; - - public static class Status { - public static final int STATUS_NONE = -1; - public static final int STATUS_COMPLETE = 0; - public static final int STATUS_PENDING = 0x20; - public static final int STATUS_FAILED = 0x40; - } - - public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + - DATE_SENT + " INTEGER NOT NULL, " + - DATE_RECEIVED + " INTEGER NOT NULL, " + - DATE_SERVER + " INTEGER DEFAULT -1, " + - THREAD_ID + " INTEGER NOT NULL REFERENCES " + ThreadTable.TABLE_NAME + " (" + ThreadTable.ID + ") ON DELETE CASCADE, " + - RECIPIENT_ID + " INTEGER NOT NULL REFERENCES " + RecipientTable.TABLE_NAME + " (" + RecipientTable.ID + ") ON DELETE CASCADE, " + - RECIPIENT_DEVICE_ID + " INTEGER, " + - TYPE + " INTEGER NOT NULL, " + - BODY + " TEXT, " + - READ + " INTEGER DEFAULT 0, " + - MMS_CONTENT_LOCATION + " TEXT, " + - MMS_EXPIRY + " INTEGER, " + - MMS_MESSAGE_TYPE + " INTEGER, " + - MMS_MESSAGE_SIZE + " INTEGER, " + - MMS_STATUS + " INTEGER, " + - MMS_TRANSACTION_ID + " TEXT, " + - SMS_SUBSCRIPTION_ID + " INTEGER DEFAULT -1, " + - RECEIPT_TIMESTAMP + " INTEGER DEFAULT -1, " + - DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + - READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + - VIEWED_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + - MISMATCHED_IDENTITIES + " TEXT DEFAULT NULL, " + - NETWORK_FAILURES + " TEXT DEFAULT NULL," + - EXPIRES_IN + " INTEGER DEFAULT 0, " + - EXPIRE_STARTED + " INTEGER DEFAULT 0, " + - NOTIFIED + " INTEGER DEFAULT 0, " + - QUOTE_ID + " INTEGER DEFAULT 0, " + - QUOTE_AUTHOR + " INTEGER DEFAULT 0, " + - QUOTE_BODY + " TEXT DEFAULT NULL, " + - QUOTE_MISSING + " INTEGER DEFAULT 0, " + - QUOTE_BODY_RANGES + " BLOB DEFAULT NULL," + - QUOTE_TYPE + " INTEGER DEFAULT 0," + - SHARED_CONTACTS + " TEXT DEFAULT NULL, " + - UNIDENTIFIED + " INTEGER DEFAULT 0, " + - LINK_PREVIEWS + " TEXT DEFAULT NULL, " + - VIEW_ONCE + " INTEGER DEFAULT 0, " + - REACTIONS_UNREAD + " INTEGER DEFAULT 0, " + - REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " + - REMOTE_DELETED + " INTEGER DEFAULT 0, " + - MENTIONS_SELF + " INTEGER DEFAULT 0, " + - NOTIFIED_TIMESTAMP + " INTEGER DEFAULT 0, " + - SERVER_GUID + " TEXT DEFAULT NULL, " + - MESSAGE_RANGES + " BLOB DEFAULT NULL, " + - STORY_TYPE + " INTEGER DEFAULT 0, " + - PARENT_STORY_ID + " INTEGER DEFAULT 0, " + - EXPORT_STATE + " BLOB DEFAULT NULL, " + - EXPORTED + " INTEGER DEFAULT 0, " + - SCHEDULED_DATE + " INTEGER DEFAULT -1);"; - - private static final String INDEX_THREAD_DATE = "mms_thread_date_index"; - private static final String INDEX_THREAD_STORY_SCHEDULED_DATE = "mms_thread_story_parent_story_scheduled_date_index"; - - public static final String[] CREATE_INDEXS = { - "CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON " + TABLE_NAME + "(" + READ + "," + NOTIFIED + "," + THREAD_ID + ");", - "CREATE INDEX IF NOT EXISTS mms_type_index ON " + TABLE_NAME + " (" + TYPE + ");", - "CREATE INDEX IF NOT EXISTS mms_date_sent_index ON " + TABLE_NAME + " (" + DATE_SENT + ", " + RECIPIENT_ID + ", " + THREAD_ID + ");", - "CREATE INDEX IF NOT EXISTS mms_date_server_index ON " + TABLE_NAME + " (" + DATE_SERVER + ");", - "CREATE INDEX IF NOT EXISTS " + INDEX_THREAD_DATE + " ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + ");", - "CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");", - "CREATE INDEX IF NOT EXISTS mms_story_type_index ON " + TABLE_NAME + " (" + STORY_TYPE + ");", - "CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON " + TABLE_NAME + " (" + PARENT_STORY_ID + ");", - "CREATE INDEX IF NOT EXISTS " + INDEX_THREAD_STORY_SCHEDULED_DATE + " ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + "," + SCHEDULED_DATE + ");", - "CREATE INDEX IF NOT EXISTS message_quote_id_quote_author_scheduled_date_index ON " + TABLE_NAME + " (" + QUOTE_ID + ", " + QUOTE_AUTHOR + ", " + SCHEDULED_DATE + ");", - "CREATE INDEX IF NOT EXISTS mms_exported_index ON " + TABLE_NAME + " (" + EXPORTED + ");", - "CREATE INDEX IF NOT EXISTS mms_id_type_payment_transactions_index ON " + TABLE_NAME + " (" + ID + "," + TYPE + ") WHERE " + TYPE + " & " + MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION + " != 0;" - }; - - private static final String[] MMS_PROJECTION_BASE = new String[] { - MessageTable.TABLE_NAME + "." + ID + " AS " + ID, - THREAD_ID, - DATE_SENT, - DATE_RECEIVED, - DATE_SERVER, - TYPE, - READ, - MMS_CONTENT_LOCATION, - MMS_EXPIRY, - MMS_MESSAGE_TYPE, - MMS_MESSAGE_SIZE, - MMS_STATUS, - MMS_TRANSACTION_ID, - BODY, - RECIPIENT_ID, - RECIPIENT_DEVICE_ID, - DELIVERY_RECEIPT_COUNT, - READ_RECEIPT_COUNT, - MISMATCHED_IDENTITIES, - NETWORK_FAILURES, - SMS_SUBSCRIPTION_ID, - EXPIRES_IN, - EXPIRE_STARTED, - NOTIFIED, - QUOTE_ID, - QUOTE_AUTHOR, - QUOTE_BODY, - QUOTE_TYPE, - QUOTE_MISSING, - QUOTE_BODY_RANGES, - SHARED_CONTACTS, - LINK_PREVIEWS, - UNIDENTIFIED, - VIEW_ONCE, - REACTIONS_UNREAD, - REACTIONS_LAST_SEEN, - REMOTE_DELETED, - MENTIONS_SELF, - NOTIFIED_TIMESTAMP, - VIEWED_RECEIPT_COUNT, - RECEIPT_TIMESTAMP, - MESSAGE_RANGES, - STORY_TYPE, - PARENT_STORY_ID, - SCHEDULED_DATE, - }; - - private static final String[] MMS_PROJECTION = SqlUtil.appendArg(MMS_PROJECTION_BASE, "NULL AS " + AttachmentTable.ATTACHMENT_JSON_ALIAS); - - private static final String[] MMS_PROJECTION_WITH_ATTACHMENTS = SqlUtil.appendArg(MMS_PROJECTION_BASE, "json_group_array(json_object(" + - "'" + AttachmentTable.ROW_ID + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.ROW_ID + ", " + - "'" + AttachmentTable.UNIQUE_ID + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.UNIQUE_ID + ", " + - "'" + AttachmentTable.MMS_ID + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.MMS_ID + ", " + - "'" + AttachmentTable.SIZE + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.SIZE + ", " + - "'" + AttachmentTable.FILE_NAME + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.FILE_NAME + ", " + - "'" + AttachmentTable.DATA + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.DATA + ", " + - "'" + AttachmentTable.CONTENT_TYPE + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.CONTENT_TYPE + ", " + - "'" + AttachmentTable.CDN_NUMBER + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.CDN_NUMBER + ", " + - "'" + AttachmentTable.CONTENT_LOCATION + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.CONTENT_LOCATION + ", " + - "'" + AttachmentTable.FAST_PREFLIGHT_ID + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.FAST_PREFLIGHT_ID + "," + - "'" + AttachmentTable.VOICE_NOTE + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.VOICE_NOTE + "," + - "'" + AttachmentTable.BORDERLESS + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.BORDERLESS + "," + - "'" + AttachmentTable.VIDEO_GIF + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.VIDEO_GIF + "," + - "'" + AttachmentTable.WIDTH + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.WIDTH + "," + - "'" + AttachmentTable.HEIGHT + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.HEIGHT + "," + - "'" + AttachmentTable.QUOTE + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.QUOTE + ", " + - "'" + AttachmentTable.CONTENT_DISPOSITION + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.CONTENT_DISPOSITION + ", " + - "'" + AttachmentTable.NAME + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.NAME + ", " + - "'" + AttachmentTable.TRANSFER_STATE + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.TRANSFER_STATE + ", " + - "'" + AttachmentTable.CAPTION + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.CAPTION + ", " + - "'" + AttachmentTable.STICKER_PACK_ID + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.STICKER_PACK_ID + ", " + - "'" + AttachmentTable.STICKER_PACK_KEY + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.STICKER_PACK_KEY + ", " + - "'" + AttachmentTable.STICKER_ID + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.STICKER_ID + ", " + - "'" + AttachmentTable.STICKER_EMOJI + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.STICKER_EMOJI + ", " + - "'" + AttachmentTable.VISUAL_HASH + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.VISUAL_HASH + ", " + - "'" + AttachmentTable.TRANSFORM_PROPERTIES + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.TRANSFORM_PROPERTIES + ", " + - "'" + AttachmentTable.DISPLAY_ORDER + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.DISPLAY_ORDER + ", " + - "'" + AttachmentTable.UPLOAD_TIMESTAMP + "', " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.UPLOAD_TIMESTAMP + - ")) AS " + AttachmentTable.ATTACHMENT_JSON_ALIAS); - - private static final String THREAD_ID_WHERE = THREAD_ID + " = ?"; - private static final String[] THREAD_ID_PROJECTION = new String[] { THREAD_ID }; - private static final String IS_STORY_CLAUSE = STORY_TYPE + " > 0 AND " + REMOTE_DELETED + " = 0"; - private static final String RAW_ID_WHERE = TABLE_NAME + "._id = ?"; - - private static final String SNIPPET_QUERY = "SELECT " + MessageTable.ID + ", " + MessageTable.TYPE + ", " + MessageTable.DATE_RECEIVED + " FROM " + MessageTable.TABLE_NAME + " " + - "WHERE " + MessageTable.THREAD_ID + " = ? AND " + - MessageTable.TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + " AND " + - MessageTable.STORY_TYPE + " = 0 AND " + - MessageTable.PARENT_STORY_ID + " <= 0 AND " + - MessageTable.SCHEDULED_DATE + " = -1 AND " + - MessageTable.TYPE + " NOT IN (" + MessageTypes.PROFILE_CHANGE_TYPE + ", " + MessageTypes.GV1_MIGRATION_TYPE + ", " + MessageTypes.CHANGE_NUMBER_TYPE + ", " + MessageTypes.BOOST_REQUEST_TYPE + ", " + MessageTypes.SMS_EXPORT_TYPE + ") " + - "ORDER BY " + MessageTable.DATE_RECEIVED + " DESC " + - "LIMIT 1"; - - private final EarlyReceiptCache earlyDeliveryReceiptCache = new EarlyReceiptCache("MmsDelivery"); - - public MessageTable(Context context, SignalDatabase databaseHelper) { - super(context, databaseHelper); - } - - public @Nullable RecipientId getOldestGroupUpdateSender(long threadId, long minimumDateReceived) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - String[] columns = new String[]{RECIPIENT_ID}; - String query = THREAD_ID + " = ? AND " + TYPE + " & ? AND " + DATE_RECEIVED + " >= ?"; - long type = MessageTypes.SECURE_MESSAGE_BIT | MessageTypes.PUSH_MESSAGE_BIT | MessageTypes.GROUP_UPDATE_BIT | MessageTypes.BASE_INBOX_TYPE; - String[] args = new String[]{String.valueOf(threadId), String.valueOf(type), String.valueOf(minimumDateReceived)}; - String limit = "1"; - - try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, limit)) { - if (cursor.moveToFirst()) { - return RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); - } - } - - return null; - } - - public Cursor getExpirationStartedMessages() { - String where = EXPIRE_STARTED + " > 0"; - return rawQuery(where, null); - } - - public Cursor getMessageCursor(long messageId) { - return internalGetMessage(messageId); - } - - public boolean hasReceivedAnyCallsSince(long threadId, long timestamp) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] projection = SqlUtil.buildArgs(TYPE); - String selection = THREAD_ID + " = ? AND " + DATE_RECEIVED + " > ? AND (" + TYPE + " = ? OR " + TYPE + " = ? OR " + TYPE + " = ? OR " + TYPE + " =?)"; - String[] selectionArgs = SqlUtil.buildArgs(threadId, - timestamp, - MessageTypes.INCOMING_AUDIO_CALL_TYPE, - MessageTypes.INCOMING_VIDEO_CALL_TYPE, - MessageTypes.MISSED_AUDIO_CALL_TYPE, - MessageTypes.MISSED_VIDEO_CALL_TYPE); - - try (Cursor cursor = db.query(TABLE_NAME, projection, selection, selectionArgs, null, null, null)) { - return cursor != null && cursor.moveToFirst(); - } - } - - public void markAsEndSession(long id) { - updateTypeBitmask(id, MessageTypes.KEY_EXCHANGE_MASK, MessageTypes.END_SESSION_BIT); - } - - public void markAsInvalidVersionKeyExchange(long id) { - updateTypeBitmask(id, 0, MessageTypes.KEY_EXCHANGE_INVALID_VERSION_BIT); - } - - public void markAsSecure(long id) { - updateTypeBitmask(id, 0, MessageTypes.SECURE_MESSAGE_BIT); - } - - public void markAsDecryptFailed(long id) { - updateTypeBitmask(id, MessageTypes.ENCRYPTION_MASK, MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT); - } - - public void markAsNoSession(long id) { - updateTypeBitmask(id, MessageTypes.ENCRYPTION_MASK, MessageTypes.ENCRYPTION_REMOTE_NO_SESSION_BIT); - } - - public void markAsUnsupportedProtocolVersion(long id) { - updateTypeBitmask(id, MessageTypes.BASE_TYPE_MASK, MessageTypes.UNSUPPORTED_MESSAGE_TYPE); - } - - public void markAsInvalidMessage(long id) { - updateTypeBitmask(id, MessageTypes.BASE_TYPE_MASK, MessageTypes.INVALID_MESSAGE_TYPE); - } - - public void markAsLegacyVersion(long id) { - updateTypeBitmask(id, MessageTypes.ENCRYPTION_MASK, MessageTypes.ENCRYPTION_REMOTE_LEGACY_BIT); - } - - public void markAsMissedCall(long id, boolean isVideoOffer) { - updateTypeBitmask(id, MessageTypes.TOTAL_MASK, isVideoOffer ? MessageTypes.MISSED_VIDEO_CALL_TYPE : MessageTypes.MISSED_AUDIO_CALL_TYPE); - } - - public void markSmsStatus(long id, int status) { - Log.i(TAG, "Updating ID: " + id + " to status: " + status); - ContentValues contentValues = new ContentValues(); - contentValues.put(MMS_STATUS, status); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {id+""}); - - long threadId = getThreadIdForMessage(id); - SignalDatabase.threads().update(threadId, false); - notifyConversationListeners(threadId); - } - - private void updateTypeBitmask(long id, long maskOff, long maskOn) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - long threadId; - - db.beginTransaction(); - try { - db.execSQL("UPDATE " + TABLE_NAME + - " SET " + TYPE + " = (" + TYPE + " & " + (MessageTypes.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + - " WHERE " + ID + " = ?", SqlUtil.buildArgs(id)); - - threadId = getThreadIdForMessage(id); - - SignalDatabase.threads().updateSnippetTypeSilently(threadId); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(id)); - ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); - } - - private InsertResult updateMessageBodyAndType(long messageId, String body, long maskOff, long maskOn) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.execSQL("UPDATE " + TABLE_NAME + " SET " + BODY + " = ?, " + - TYPE + " = (" + TYPE + " & " + (MessageTypes.TOTAL_MASK - maskOff) + " | " + maskOn + ") " + - "WHERE " + ID + " = ?", - new String[] {body, messageId + ""}); - - long threadId = getThreadIdForMessage(messageId); - - SignalDatabase.threads().update(threadId, true); - notifyConversationListeners(threadId); - - return new InsertResult(messageId, threadId); - } - - public InsertResult updateBundleMessageBody(long messageId, String body) { - long type = MessageTypes.BASE_INBOX_TYPE | MessageTypes.SECURE_MESSAGE_BIT | MessageTypes.PUSH_MESSAGE_BIT; - return updateMessageBodyAndType(messageId, body, MessageTypes.TOTAL_MASK, type); - } - - public @NonNull List getViewedIncomingMessages(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] columns = new String[]{ ID, RECIPIENT_ID, DATE_SENT, TYPE, THREAD_ID, STORY_TYPE}; - String where = THREAD_ID + " = ? AND " + VIEWED_RECEIPT_COUNT + " > 0 AND " + TYPE + " & " + MessageTypes.BASE_INBOX_TYPE + " = " + MessageTypes.BASE_INBOX_TYPE; - String[] args = SqlUtil.buildArgs(threadId); - - - try (Cursor cursor = db.query(TABLE_NAME, columns, where, args, null, null, null, null)) { - if (cursor == null) { - return Collections.emptyList(); - } - - List results = new ArrayList<>(cursor.getCount()); - while (cursor.moveToNext()) { - long messageId = CursorUtil.requireLong(cursor, ID); - RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); - long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); - SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); - StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); - - results.add(new MarkedMessageInfo(threadId, syncMessageId, new MessageId(messageId), null, storyType)); - } - - return results; - } - } - - public @Nullable MarkedMessageInfo setIncomingMessageViewed(long messageId) { - List results = setIncomingMessagesViewed(Collections.singletonList(messageId)); - - if (results.isEmpty()) { - return null; - } else { - return results.get(0); - } - } - - public @NonNull List setIncomingMessagesViewed(@NonNull List messageIds) { - if (messageIds.isEmpty()) { - return Collections.emptyList(); - } - - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - String[] columns = new String[]{ ID, RECIPIENT_ID, DATE_SENT, TYPE, THREAD_ID, STORY_TYPE}; - String where = ID + " IN (" + Util.join(messageIds, ",") + ") AND " + VIEWED_RECEIPT_COUNT + " = 0"; - List results = new LinkedList<>(); - - database.beginTransaction(); - try (Cursor cursor = database.query(TABLE_NAME, columns, where, null, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - long type = CursorUtil.requireLong(cursor, TYPE); - if (MessageTypes.isSecureType(type) && MessageTypes.isInboxType(type)) { - long messageId = CursorUtil.requireLong(cursor, ID); - long threadId = CursorUtil.requireLong(cursor, THREAD_ID); - RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); - long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); - SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); - StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); - - results.add(new MarkedMessageInfo(threadId, syncMessageId, new MessageId(messageId), null, storyType)); - - ContentValues contentValues = new ContentValues(); - contentValues.put(VIEWED_RECEIPT_COUNT, 1); - contentValues.put(RECEIPT_TIMESTAMP, System.currentTimeMillis()); - - database.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(CursorUtil.requireLong(cursor, ID))); - } - } - database.setTransactionSuccessful(); - } finally { - database.endTransaction(); - } - - Set threadsUpdated = Stream.of(results) - .map(MarkedMessageInfo::getThreadId) - .collect(Collectors.toSet()); - - Set storyRecipientsUpdated = results.stream() - .filter(it -> it.storyType.isStory()) - .map(it -> SignalDatabase.threads().getRecipientIdForThreadId(it.getThreadId())) - .filter(it -> it != null) - .collect(java.util.stream.Collectors.toSet()); - - notifyConversationListeners(threadsUpdated); - notifyConversationListListeners(); - - ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(storyRecipientsUpdated); - - return results; - } - - public @NonNull List setOutgoingGiftsRevealed(@NonNull List messageIds) { - String[] projection = SqlUtil.buildArgs(ID, RECIPIENT_ID, DATE_SENT, THREAD_ID, STORY_TYPE); - String where = ID + " IN (" + Util.join(messageIds, ",") + ") AND (" + getOutgoingTypeClause() + ") AND (" + TYPE + " & " + MessageTypes.SPECIAL_TYPES_MASK + " = " + MessageTypes.SPECIAL_TYPE_GIFT_BADGE + ") AND " + VIEWED_RECEIPT_COUNT + " = 0"; - List results = new LinkedList<>(); - - getWritableDatabase().beginTransaction(); - try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, projection, where, null, null, null, null)) { - while (cursor.moveToNext()) { - long messageId = CursorUtil.requireLong(cursor, ID); - long threadId = CursorUtil.requireLong(cursor, THREAD_ID); - RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); - long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); - SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); - StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); - - results.add(new MarkedMessageInfo(threadId, syncMessageId, new MessageId(messageId), null, storyType)); - - ContentValues contentValues = new ContentValues(); - contentValues.put(VIEWED_RECEIPT_COUNT, 1); - contentValues.put(RECEIPT_TIMESTAMP, System.currentTimeMillis()); - - getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(messageId)); - } - getWritableDatabase().setTransactionSuccessful(); - } finally { - getWritableDatabase().endTransaction(); - } - - Set threadsUpdated = Stream.of(results) - .map(MarkedMessageInfo::getThreadId) - .collect(Collectors.toSet()); - - notifyConversationListeners(threadsUpdated); - - return results; - } - - public @NonNull InsertResult insertCallLog(@NonNull RecipientId recipientId, long type, long timestamp) { - boolean unread = MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type); - Recipient recipient = Recipient.resolved(recipientId); - long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); - - ContentValues values = new ContentValues(7); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, timestamp); - values.put(READ, unread ? 0 : 1); - values.put(TYPE, type); - values.put(THREAD_ID, threadId); - - long messageId = getWritableDatabase().insert(TABLE_NAME, null, values); - - if (unread) { - SignalDatabase.threads().incrementUnread(threadId, 1, 0); - } - - SignalDatabase.threads().update(threadId, true); - - notifyConversationListeners(threadId); - TrimThreadJob.enqueueAsync(threadId); - - return new InsertResult(messageId, threadId); - } - - public void updateCallLog(long messageId, long type) { - boolean unread = MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type); - ContentValues values = new ContentValues(2); - values.put(TYPE, type); - values.put(READ, unread ? 0 : 1); - getWritableDatabase().update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(messageId)); - - long threadId = getThreadIdForMessage(messageId); - Recipient recipient = SignalDatabase.threads().getRecipientForThreadId(threadId); - - if (unread) { - SignalDatabase.threads().incrementUnread(threadId, 1, 0); - } - - SignalDatabase.threads().update(threadId, true); - - notifyConversationListeners(threadId); - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - } - - public void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, - @NonNull RecipientId sender, - long timestamp, - @Nullable String peekGroupCallEraId, - @NonNull Collection peekJoinedUuids, - boolean isCallFull) - { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - Recipient recipient = Recipient.resolved(groupRecipientId); - long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); - boolean peerEraIdSameAsPrevious = updatePreviousGroupCall(threadId, peekGroupCallEraId, peekJoinedUuids, isCallFull); - - try { - db.beginTransaction(); - - if (!peerEraIdSameAsPrevious && !Util.isEmpty(peekGroupCallEraId)) { - Recipient self = Recipient.self(); - boolean markRead = peekJoinedUuids.contains(self.requireServiceId().uuid()) || self.getId().equals(sender); - - byte[] updateDetails = GroupCallUpdateDetails.newBuilder() - .setEraId(Util.emptyIfNull(peekGroupCallEraId)) - .setStartedCallUuid(Recipient.resolved(sender).requireServiceId().toString()) - .setStartedCallTimestamp(timestamp) - .addAllInCallUuids(Stream.of(peekJoinedUuids).map(UUID::toString).toList()) - .setIsCallFull(isCallFull) - .build() - .toByteArray(); - - String body = Base64.encodeBytes(updateDetails); - - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, sender.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, timestamp); - values.put(DATE_SENT, timestamp); - values.put(READ, markRead ? 1 : 0); - values.put(BODY, body); - values.put(TYPE, MessageTypes.GROUP_CALL_TYPE); - values.put(THREAD_ID, threadId); - - db.insert(TABLE_NAME, null, values); - - SignalDatabase.threads().incrementUnread(threadId, 1, 0); - } - - SignalDatabase.threads().update(threadId, true); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - notifyConversationListeners(threadId); - TrimThreadJob.enqueueAsync(threadId); - } - - public void insertOrUpdateGroupCall(@NonNull RecipientId groupRecipientId, - @NonNull RecipientId sender, - long timestamp, - @Nullable String messageGroupCallEraId) - { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - long threadId; - - try { - db.beginTransaction(); - - Recipient recipient = Recipient.resolved(groupRecipientId); - - threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); - - String where = TYPE + " = ? AND " + THREAD_ID + " = ?"; - String[] args = SqlUtil.buildArgs(MessageTypes.GROUP_CALL_TYPE, threadId); - boolean sameEraId = false; - - try (MmsReader reader = new MmsReader(db.query(TABLE_NAME, MMS_PROJECTION, where, args, null, null, DATE_RECEIVED + " DESC", "1"))) { - MessageRecord record = reader.getNext(); - if (record != null) { - GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.getBody()); - - sameEraId = groupCallUpdateDetails.getEraId().equals(messageGroupCallEraId) && !Util.isEmpty(messageGroupCallEraId); - - if (!sameEraId) { - String body = GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, Collections.emptyList(), false); - - ContentValues contentValues = new ContentValues(); - contentValues.put(BODY, body); - - db.update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(record.getId())); - } - } - } - - if (!sameEraId && !Util.isEmpty(messageGroupCallEraId)) { - byte[] updateDetails = GroupCallUpdateDetails.newBuilder() - .setEraId(Util.emptyIfNull(messageGroupCallEraId)) - .setStartedCallUuid(Recipient.resolved(sender).requireServiceId().toString()) - .setStartedCallTimestamp(timestamp) - .addAllInCallUuids(Collections.emptyList()) - .setIsCallFull(false) - .build() - .toByteArray(); - - String body = Base64.encodeBytes(updateDetails); - - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, sender.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, timestamp); - values.put(DATE_SENT, timestamp); - values.put(READ, 0); - values.put(BODY, body); - values.put(TYPE, MessageTypes.GROUP_CALL_TYPE); - values.put(THREAD_ID, threadId); - - db.insert(TABLE_NAME, null, values); - - SignalDatabase.threads().incrementUnread(threadId, 1, 0); - } - - SignalDatabase.threads().update(threadId, true); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - notifyConversationListeners(threadId); - TrimThreadJob.enqueueAsync(threadId); - } - - public boolean updatePreviousGroupCall(long threadId, @Nullable String peekGroupCallEraId, @NonNull Collection peekJoinedUuids, boolean isCallFull) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - String where = TYPE + " = ? AND " + THREAD_ID + " = ?"; - String[] args = SqlUtil.buildArgs(MessageTypes.GROUP_CALL_TYPE, threadId); - boolean sameEraId = false; - - try (MmsReader reader = new MmsReader(db.query(TABLE_NAME, MMS_PROJECTION, where, args, null, null, DATE_RECEIVED + " DESC", "1"))) { - MessageRecord record = reader.getNext(); - if (record == null) { - return false; - } - - GroupCallUpdateDetails groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.getBody()); - boolean containsSelf = peekJoinedUuids.contains(SignalStore.account().requireAci().uuid()); - - sameEraId = groupCallUpdateDetails.getEraId().equals(peekGroupCallEraId) && !Util.isEmpty(peekGroupCallEraId); - - List inCallUuids = sameEraId ? Stream.of(peekJoinedUuids).map(UUID::toString).toList() - : Collections.emptyList(); - - String body = GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, inCallUuids, isCallFull); - - ContentValues contentValues = new ContentValues(); - contentValues.put(BODY, body); - - if (sameEraId && containsSelf) { - contentValues.put(READ, 1); - } - - SqlUtil.Query query = SqlUtil.buildTrueUpdateQuery(ID_WHERE, SqlUtil.buildArgs(record.getId()), contentValues); - boolean updated = db.update(TABLE_NAME, contentValues, query.getWhere(), query.getWhereArgs()) > 0; - - if (updated) { - notifyConversationListeners(threadId); - } - } - - return sameEraId; - } - - public Optional insertMessageInbox(IncomingTextMessage message, long type) { - boolean tryToCollapseJoinRequestEvents = false; - - if (message.isJoined()) { - type = (type & (MessageTypes.TOTAL_MASK - MessageTypes.BASE_TYPE_MASK)) | MessageTypes.JOINED_TYPE; - } else if (message.isPreKeyBundle()) { - type |= MessageTypes.KEY_EXCHANGE_BIT | MessageTypes.KEY_EXCHANGE_BUNDLE_BIT; - } else if (message.isSecureMessage()) { - type |= MessageTypes.SECURE_MESSAGE_BIT; - } else if (message.isGroup()) { - IncomingGroupUpdateMessage incomingGroupUpdateMessage = (IncomingGroupUpdateMessage) message; - - type |= MessageTypes.SECURE_MESSAGE_BIT; - - if (incomingGroupUpdateMessage.isGroupV2()) { - type |= MessageTypes.GROUP_V2_BIT | MessageTypes.GROUP_UPDATE_BIT; - if (incomingGroupUpdateMessage.isJustAGroupLeave()) { - type |= MessageTypes.GROUP_LEAVE_BIT; - } else if (incomingGroupUpdateMessage.isCancelJoinRequest()) { - tryToCollapseJoinRequestEvents = true; - } - } else if (incomingGroupUpdateMessage.isUpdate()) { - type |= MessageTypes.GROUP_UPDATE_BIT; - } else if (incomingGroupUpdateMessage.isQuit()) { - type |= MessageTypes.GROUP_LEAVE_BIT; - } - - } else if (message.isEndSession()) { - type |= MessageTypes.SECURE_MESSAGE_BIT; - type |= MessageTypes.END_SESSION_BIT; - } - - if (message.isPush()) type |= MessageTypes.PUSH_MESSAGE_BIT; - if (message.isIdentityUpdate()) type |= MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT; - if (message.isContentPreKeyBundle()) type |= MessageTypes.KEY_EXCHANGE_CONTENT_FORMAT; - - if (message.isIdentityVerified()) type |= MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; - else if (message.isIdentityDefault()) type |= MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT; - - Recipient recipient = Recipient.resolved(message.getSender()); - - Recipient groupRecipient; - - if (message.getGroupId() == null) { - groupRecipient = null; - } else { - RecipientId id = SignalDatabase.recipients().getOrInsertFromPossiblyMigratedGroupId(message.getGroupId()); - groupRecipient = Recipient.resolved(id); - } - - boolean silent = message.isIdentityUpdate() || - message.isIdentityVerified() || - message.isIdentityDefault() || - message.isJustAGroupLeave() || - (type & MessageTypes.GROUP_UPDATE_BIT) > 0; - - boolean unread = !silent && (message.isSecureMessage() || - message.isGroup() || - message.isPreKeyBundle() || - Util.isDefaultSmsProvider(context)); - - long threadId; - - if (groupRecipient == null) threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); - else threadId = SignalDatabase.threads().getOrCreateThreadIdFor(groupRecipient); - - if (tryToCollapseJoinRequestEvents) { - final Optional result = collapseJoinRequestEventsIfPossible(threadId, (IncomingGroupUpdateMessage) message); - if (result.isPresent()) { - return result; - } - } - - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, message.getSender().serialize()); - values.put(RECIPIENT_DEVICE_ID, message.getSenderDeviceId()); - values.put(DATE_RECEIVED, message.getReceivedTimestampMillis()); - values.put(DATE_SENT, message.getSentTimestampMillis()); - values.put(DATE_SERVER, message.getServerTimestampMillis()); - values.put(READ, unread ? 0 : 1); - values.put(SMS_SUBSCRIPTION_ID, message.getSubscriptionId()); - values.put(EXPIRES_IN, message.getExpiresIn()); - values.put(UNIDENTIFIED, message.isUnidentified()); - values.put(BODY, message.getMessageBody()); - values.put(TYPE, type); - values.put(THREAD_ID, threadId); - values.put(SERVER_GUID, message.getServerGuid()); - - if (message.isPush() && isDuplicate(message, threadId)) { - Log.w(TAG, "Duplicate message (" + message.getSentTimestampMillis() + "), ignoring..."); - return Optional.empty(); - } else { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - long messageId = db.insert(TABLE_NAME, null, values); - - if (unread) { - SignalDatabase.threads().incrementUnread(threadId, 1, 0); - } - - if (!silent) { - SignalDatabase.threads().update(threadId, true); - } - - if (message.getSubscriptionId() != -1) { - SignalDatabase.recipients().setDefaultSubscriptionId(recipient.getId(), message.getSubscriptionId()); - } - - notifyConversationListeners(threadId); - - if (!silent) { - TrimThreadJob.enqueueAsync(threadId); - } - - return Optional.of(new InsertResult(messageId, threadId)); - } - } - - public Optional insertMessageInbox(IncomingTextMessage message) { - return insertMessageInbox(message, MessageTypes.BASE_INBOX_TYPE); - } - - public void insertProfileNameChangeMessages(@NonNull Recipient recipient, @NonNull String newProfileName, @NonNull String previousProfileName) { - ThreadTable threadTable = SignalDatabase.threads(); - List groupRecords = SignalDatabase.groups().getGroupsContainingMember(recipient.getId(), false); - List threadIdsToUpdate = new LinkedList<>(); - - byte[] profileChangeDetails = ProfileChangeDetails.newBuilder() - .setProfileNameChange(ProfileChangeDetails.StringChange.newBuilder() - .setNew(newProfileName) - .setPrevious(previousProfileName)) - .build() - .toByteArray(); - - String body = Base64.encodeBytes(profileChangeDetails); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.beginTransaction(); - - try { - threadIdsToUpdate.add(threadTable.getThreadIdFor(recipient.getId())); - for (GroupRecord groupRecord : groupRecords) { - if (groupRecord.isActive()) { - threadIdsToUpdate.add(threadTable.getThreadIdFor(groupRecord.getRecipientId())); - } - } - - Stream.of(threadIdsToUpdate) - .withoutNulls() - .forEach(threadId -> { - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipient.getId().serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, System.currentTimeMillis()); - values.put(READ, 1); - values.put(TYPE, MessageTypes.PROFILE_CHANGE_TYPE); - values.put(THREAD_ID, threadId); - values.put(BODY, body); - - db.insert(TABLE_NAME, null, values); - - notifyConversationListeners(threadId); - }); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - Stream.of(threadIdsToUpdate) - .withoutNulls() - .forEach(TrimThreadJob::enqueueAsync); - } - - public void insertGroupV1MigrationEvents(@NonNull RecipientId recipientId, - long threadId, - @NonNull GroupMigrationMembershipChange membershipChange) - { - insertGroupV1MigrationNotification(recipientId, threadId); - - if (!membershipChange.isEmpty()) { - insertGroupV1MigrationMembershipChanges(recipientId, threadId, membershipChange); - } - - notifyConversationListeners(threadId); - TrimThreadJob.enqueueAsync(threadId); - } - - private void insertGroupV1MigrationNotification(@NonNull RecipientId recipientId, long threadId) { - insertGroupV1MigrationMembershipChanges(recipientId, threadId, GroupMigrationMembershipChange.empty()); - } - - private void insertGroupV1MigrationMembershipChanges(@NonNull RecipientId recipientId, - long threadId, - @NonNull GroupMigrationMembershipChange membershipChange) - { - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, System.currentTimeMillis()); - values.put(READ, 1); - values.put(TYPE, MessageTypes.GV1_MIGRATION_TYPE); - values.put(THREAD_ID, threadId); - - if (!membershipChange.isEmpty()) { - values.put(BODY, membershipChange.serialize()); - } - - databaseHelper.getSignalWritableDatabase().insert(TABLE_NAME, null, values); - } - - public void insertNumberChangeMessages(@NonNull RecipientId recipientId) { - ThreadTable threadTable = SignalDatabase.threads(); - List groupRecords = SignalDatabase.groups().getGroupsContainingMember(recipientId, false); - List threadIdsToUpdate = new LinkedList<>(); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.beginTransaction(); - - try { - threadIdsToUpdate.add(threadTable.getThreadIdFor(recipientId)); - for (GroupRecord groupRecord : groupRecords) { - if (groupRecord.isActive()) { - threadIdsToUpdate.add(threadTable.getThreadIdFor(groupRecord.getRecipientId())); - } - } - - threadIdsToUpdate.stream() - .filter(Objects::nonNull) - .forEach(threadId -> { - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, System.currentTimeMillis()); - values.put(READ, 1); - values.put(TYPE, MessageTypes.CHANGE_NUMBER_TYPE); - values.put(THREAD_ID, threadId); - values.putNull(BODY); - - db.insert(TABLE_NAME, null, values); - }); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - threadIdsToUpdate.stream() - .filter(Objects::nonNull) - .forEach(threadId -> { - TrimThreadJob.enqueueAsync(threadId); - SignalDatabase.threads().update(threadId, true); - notifyConversationListeners(threadId); - }); - } - - public void insertBoostRequestMessage(@NonNull RecipientId recipientId, long threadId) { - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, System.currentTimeMillis()); - values.put(READ, 1); - values.put(TYPE, MessageTypes.BOOST_REQUEST_TYPE); - values.put(THREAD_ID, threadId); - values.putNull(BODY); - - getWritableDatabase().insert(TABLE_NAME, null, values); - } - - public void insertThreadMergeEvent(@NonNull RecipientId recipientId, long threadId, @NonNull ThreadMergeEvent event) { - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, System.currentTimeMillis()); - values.put(READ, 1); - values.put(TYPE, MessageTypes.THREAD_MERGE_TYPE); - values.put(THREAD_ID, threadId); - values.put(BODY, Base64.encodeBytes(event.toByteArray())); - - getWritableDatabase().insert(TABLE_NAME, null, values); - - ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId); - } - - public void insertSessionSwitchoverEvent(@NonNull RecipientId recipientId, long threadId, @NonNull SessionSwitchoverEvent event) { - if (!FeatureFlags.phoneNumberPrivacy()) { - throw new IllegalStateException("Should not occur in a non-PNP world!"); - } - - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, System.currentTimeMillis()); - values.put(READ, 1); - values.put(TYPE, MessageTypes.SESSION_SWITCHOVER_TYPE); - values.put(THREAD_ID, threadId); - values.put(BODY, Base64.encodeBytes(event.toByteArray())); - - getWritableDatabase().insert(TABLE_NAME, null, values); - - ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId); - } - - public void insertSmsExportMessage(@NonNull RecipientId recipientId, long threadId) { - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, 1); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, System.currentTimeMillis()); - values.put(READ, 1); - values.put(TYPE, MessageTypes.SMS_EXPORT_TYPE); - values.put(THREAD_ID, threadId); - values.putNull(BODY); - - boolean updated = SQLiteDatabaseExtensionsKt.withinTransaction(getWritableDatabase(), db -> { - if (SignalDatabase.messages().hasSmsExportMessage(threadId)) { - return false; - } else { - db.insert(TABLE_NAME, null, values); - return true; - } - }); - - if (updated) { - ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId); - } - } - - public void endTransaction(SQLiteDatabase database) { - database.endTransaction(); - } - - public void ensureMigration() { - databaseHelper.getSignalWritableDatabase(); - } - - public boolean isStory(long messageId) { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[]{"1"}; - String where = IS_STORY_CLAUSE + " AND " + ID + " = ?"; - String[] whereArgs = SqlUtil.buildArgs(messageId); - - try (Cursor cursor = database.query(TABLE_NAME, projection, where, whereArgs, null, null, null, "1")) { - return cursor != null && cursor.moveToFirst(); - } - } - - public @NonNull MessageTable.Reader getOutgoingStoriesTo(@NonNull RecipientId recipientId) { - Recipient recipient = Recipient.resolved(recipientId); - Long threadId = null; - - if (recipient.isGroup()) { - threadId = SignalDatabase.threads().getThreadIdFor(recipientId); - } - - String where = IS_STORY_CLAUSE + " AND (" + getOutgoingTypeClause() + ")"; - - final String[] whereArgs; - if (threadId == null) { - where += " AND " + RECIPIENT_ID + " = ?"; - whereArgs = SqlUtil.buildArgs(recipientId); - } else { - where += " AND " + THREAD_ID_WHERE; - whereArgs = SqlUtil.buildArgs(threadId); - } - - return new MmsReader(rawQuery(where, whereArgs)); - } - - public @NonNull MessageTable.Reader getAllOutgoingStories(boolean reverse, int limit) { - String where = IS_STORY_CLAUSE + " AND (" + getOutgoingTypeClause() + ")"; - - return new MmsReader(rawQuery(where, null, reverse, limit)); - } - - public @NonNull MessageTable.Reader getAllOutgoingStoriesAt(long sentTimestamp) { - String where = IS_STORY_CLAUSE + " AND " + DATE_SENT + " = ? AND (" + getOutgoingTypeClause() + ")"; - String[] whereArgs = SqlUtil.buildArgs(sentTimestamp); - Cursor cursor = rawQuery(where, whereArgs, false, -1L); - - return new MmsReader(cursor); - } - - public @NonNull List markAllIncomingStoriesRead() { - String where = IS_STORY_CLAUSE + " AND NOT (" + getOutgoingTypeClause() + ") AND " + READ + " = 0"; - - List markedMessageInfos = setMessagesRead(where, null); - notifyConversationListListeners(); - - return markedMessageInfos; - } - - public void markOnboardingStoryRead() { - RecipientId recipientId = SignalStore.releaseChannelValues().getReleaseChannelRecipientId(); - if (recipientId == null) { - return; - } - - String where = IS_STORY_CLAUSE + " AND NOT (" + getOutgoingTypeClause() + ") AND " + READ + " = 0 AND " + RECIPIENT_ID + " = ?"; - - List markedMessageInfos = setMessagesRead(where, SqlUtil.buildArgs(recipientId)); - if (!markedMessageInfos.isEmpty()) { - notifyConversationListListeners(); - } - } - - public @NonNull MessageTable.Reader getAllStoriesFor(@NonNull RecipientId recipientId, int limit) { - long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipientId); - String where = IS_STORY_CLAUSE + " AND " + THREAD_ID_WHERE; - String[] whereArgs = SqlUtil.buildArgs(threadId); - Cursor cursor = rawQuery(where, whereArgs, false, limit); - - return new MmsReader(cursor); - } - - public @NonNull MessageTable.Reader getUnreadStories(@NonNull RecipientId recipientId, int limit) { - final long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipientId); - final String query = IS_STORY_CLAUSE + - " AND NOT (" + getOutgoingTypeClause() + ") " + - " AND " + THREAD_ID_WHERE + - " AND " + VIEWED_RECEIPT_COUNT + " = ?"; - final String[] args = SqlUtil.buildArgs(threadId, 0); - - return new MmsReader(rawQuery(query, args, false, limit)); - } - - public @Nullable ParentStoryId.GroupReply getParentStoryIdForGroupReply(long messageId) { - String[] projection = SqlUtil.buildArgs(PARENT_STORY_ID); - String[] args = SqlUtil.buildArgs(messageId); - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, projection, ID_WHERE, args, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID)); - if (parentStoryId != null && parentStoryId.isGroupReply()) { - return (ParentStoryId.GroupReply) parentStoryId; - } else { - return null; - } - } - } - - return null; - } - - public @NonNull StoryViewState getStoryViewState(@NonNull RecipientId recipientId) { - if (!Stories.isFeatureEnabled()) { - return StoryViewState.NONE; - } - - long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipientId); - - return getStoryViewState(threadId); - } - - /** - * Synchronizes whether we've viewed a recipient's story based on incoming sync messages. - */ - public void updateViewedStories(@NonNull Set syncMessageIds) { - final String timestamps = Util.join(syncMessageIds.stream().map(SyncMessageId::getTimetamp).collect(java.util.stream.Collectors.toList()), ","); - final String[] projection = SqlUtil.buildArgs(RECIPIENT_ID); - final String where = IS_STORY_CLAUSE + " AND " + DATE_SENT + " IN (" + timestamps + ") AND NOT (" + getOutgoingTypeClause() + ") AND " + VIEWED_RECEIPT_COUNT + " > 0"; - - try { - getWritableDatabase().beginTransaction(); - try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, projection, where, null, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - Recipient recipient = Recipient.resolved(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID))); - SignalDatabase.recipients().updateLastStoryViewTimestamp(recipient.getId()); - } - } - getWritableDatabase().setTransactionSuccessful(); - } finally { - getWritableDatabase().endTransaction(); - } - } - - @VisibleForTesting - @NonNull StoryViewState getStoryViewState(long threadId) { - final String hasStoryQuery = "SELECT EXISTS(SELECT 1 FROM " + TABLE_NAME + " WHERE " + IS_STORY_CLAUSE + " AND " + THREAD_ID_WHERE + " LIMIT 1)"; - final String[] hasStoryArgs = SqlUtil.buildArgs(threadId); - final boolean hasStories; - - try (Cursor cursor = getReadableDatabase().rawQuery(hasStoryQuery, hasStoryArgs)) { - hasStories = cursor != null && cursor.moveToFirst() && !cursor.isNull(0) && cursor.getInt(0) == 1; - } - - if (!hasStories) { - return StoryViewState.NONE; - } - - final String hasUnviewedStoriesQuery = "SELECT EXISTS(SELECT 1 FROM " + TABLE_NAME + " WHERE " + IS_STORY_CLAUSE + " AND " + THREAD_ID_WHERE + " AND " + VIEWED_RECEIPT_COUNT + " = ? " + "AND NOT (" + getOutgoingTypeClause() + ") LIMIT 1)"; - final String[] hasUnviewedStoriesArgs = SqlUtil.buildArgs(threadId, 0); - final boolean hasUnviewedStories; - - try (Cursor cursor = getReadableDatabase().rawQuery(hasUnviewedStoriesQuery, hasUnviewedStoriesArgs)) { - hasUnviewedStories = cursor != null && cursor.moveToFirst() && !cursor.isNull(0) && cursor.getInt(0) == 1; - } - - if (hasUnviewedStories) { - return StoryViewState.UNVIEWED; - } else { - return StoryViewState.VIEWED; - } - } - - public boolean isOutgoingStoryAlreadyInDatabase(@NonNull RecipientId recipientId, long sentTimestamp) { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[]{"COUNT(*)"}; - String where = RECIPIENT_ID + " = ? AND " + STORY_TYPE + " > 0 AND " + DATE_SENT + " = ? AND (" + getOutgoingTypeClause() + ")"; - String[] whereArgs = SqlUtil.buildArgs(recipientId, sentTimestamp); - - try (Cursor cursor = database.query(TABLE_NAME, projection, where, whereArgs, null, null, null, "1")) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0) > 0; - } - } - - return false; - } - - public @NonNull MessageId getStoryId(@NonNull RecipientId authorId, long sentTimestamp) throws NoSuchMessageException { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[]{ID, RECIPIENT_ID}; - String where = IS_STORY_CLAUSE + " AND " + DATE_SENT + " = ?"; - String[] whereArgs = SqlUtil.buildArgs(sentTimestamp); - - try (Cursor cursor = database.query(TABLE_NAME, projection, where, whereArgs, null, null, null, "1")) { - if (cursor != null && cursor.moveToFirst()) { - RecipientId rowRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))); - - if (Recipient.self().getId().equals(authorId) || rowRecipientId.equals(authorId)) { - return new MessageId(CursorUtil.requireLong(cursor, ID)); - } - } - } - - throw new NoSuchMessageException("No story sent at " + sentTimestamp); - } - - public @NonNull List getUnreadStoryThreadRecipientIds() { - SQLiteDatabase db = getReadableDatabase(); - String query = "SELECT DISTINCT " + ThreadTable.TABLE_NAME + "." + ThreadTable.RECIPIENT_ID + "\n" - + "FROM " + TABLE_NAME + "\n" - + "JOIN " + ThreadTable.TABLE_NAME + "\n" - + "ON " + TABLE_NAME + "." + THREAD_ID + " = " + ThreadTable.TABLE_NAME + "." + ThreadTable.ID + "\n" - + "WHERE " + IS_STORY_CLAUSE + " AND (" + getOutgoingTypeClause() + ") = 0 AND " + VIEWED_RECEIPT_COUNT + " = 0 AND " + TABLE_NAME + "." + READ + " = 0"; - - try (Cursor cursor = db.rawQuery(query, null)) { - if (cursor != null) { - List recipientIds = new ArrayList<>(cursor.getCount()); - while (cursor.moveToNext()) { - recipientIds.add(RecipientId.from(cursor.getLong(0))); - } - - return recipientIds; - } - } - - return Collections.emptyList(); - } - - public @NonNull List getOrderedStoryRecipientsAndIds(boolean isOutgoingOnly) { - String where = "WHERE " + STORY_TYPE + " > 0 AND " + REMOTE_DELETED + " = 0" + (isOutgoingOnly ? " AND is_outgoing != 0" : "") + "\n"; - SQLiteDatabase db = getReadableDatabase(); - String query = "SELECT\n" - + " " + TABLE_NAME + "." + DATE_SENT + " AS sent_timestamp,\n" - + " " + TABLE_NAME + "." + ID + " AS mms_id,\n" - + " " + ThreadTable.TABLE_NAME + "." + ThreadTable.RECIPIENT_ID + ",\n" - + " (" + getOutgoingTypeClause() + ") AS is_outgoing,\n" - + " " + VIEWED_RECEIPT_COUNT + ",\n" - + " " + TABLE_NAME + "." + DATE_SENT + ",\n" - + " " + RECEIPT_TIMESTAMP + ",\n" - + " (" + getOutgoingTypeClause() + ") = 0 AND " + VIEWED_RECEIPT_COUNT + " = 0 AS is_unread\n" - + "FROM " + TABLE_NAME + "\n" - + "JOIN " + ThreadTable.TABLE_NAME + "\n" - + "ON " + TABLE_NAME + "." + THREAD_ID + " = " + ThreadTable.TABLE_NAME + "." + ThreadTable.ID + "\n" - + where - + "ORDER BY\n" - + "is_unread DESC,\n" - + "CASE\n" - + "WHEN is_outgoing = 0 AND " + VIEWED_RECEIPT_COUNT + " = 0 THEN " + MessageTable.TABLE_NAME + "." + MessageTable.DATE_SENT + "\n" - + "WHEN is_outgoing = 0 AND viewed_receipt_count > 0 THEN " + MessageTable.RECEIPT_TIMESTAMP + "\n" - + "WHEN is_outgoing = 1 THEN " + MessageTable.TABLE_NAME + "." + MessageTable.DATE_SENT + "\n" - + "END DESC"; - - List results; - try (Cursor cursor = db.rawQuery(query, null)) { - if (cursor != null) { - results = new ArrayList<>(cursor.getCount()); - - while (cursor.moveToNext()) { - results.add(new StoryResult(RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)), - CursorUtil.requireLong(cursor, "mms_id"), - CursorUtil.requireLong(cursor, "sent_timestamp"), - CursorUtil.requireBoolean(cursor, "is_outgoing"))); - } - - return results; - } - } - - return Collections.emptyList(); - } - - public @NonNull Cursor getStoryReplies(long parentStoryId) { - String where = PARENT_STORY_ID + " = ?"; - String[] whereArgs = SqlUtil.buildArgs(parentStoryId); - - return rawQuery(where, whereArgs, false, 0); - } - - public int getNumberOfStoryReplies(long parentStoryId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] columns = new String[]{"COUNT(*)"}; - String where = PARENT_STORY_ID + " = ?"; - String[] whereArgs = SqlUtil.buildArgs(parentStoryId); - - try (Cursor cursor = db.query(TABLE_NAME, columns, where, whereArgs, null, null, null, null)) { - return cursor != null && cursor.moveToNext() ? cursor.getInt(0) : 0; - } - } - - public boolean containsStories(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] columns = new String[]{"1"}; - String where = THREAD_ID_WHERE + " AND " + STORY_TYPE + " > 0"; - String[] whereArgs = SqlUtil.buildArgs(threadId); - - try (Cursor cursor = db.query(TABLE_NAME, columns, where, whereArgs, null, null, null, "1")) { - return cursor != null && cursor.moveToNext(); - } - } - - public boolean hasSelfReplyInStory(long parentStoryId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] columns = new String[]{"COUNT(*)"}; - String where = PARENT_STORY_ID + " = ? AND (" + getOutgoingTypeClause() + ")"; - String[] whereArgs = SqlUtil.buildArgs(-parentStoryId); - - try (Cursor cursor = db.query(TABLE_NAME, columns, where, whereArgs, null, null, null, null)) { - return cursor != null && cursor.moveToNext() && cursor.getInt(0) > 0; - } - } - - public boolean hasGroupReplyOrReactionInStory(long parentStoryId) { - return hasSelfReplyInStory(-parentStoryId); - } - - public @Nullable Long getOldestStorySendTimestamp(boolean hasSeenReleaseChannelStories) { - long releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories); - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] columns = new String[] { DATE_SENT }; - String where = IS_STORY_CLAUSE + " AND " + THREAD_ID + " != ?"; - String orderBy = DATE_SENT + " ASC"; - String limit = "1"; - - try (Cursor cursor = db.query(TABLE_NAME, columns, where, SqlUtil.buildArgs(releaseChannelThreadId), null, null, orderBy, limit)) { - return cursor != null && cursor.moveToNext() ? cursor.getLong(0) : null; - } - } - - private static long getReleaseChannelThreadId(boolean hasSeenReleaseChannelStories) { - if (hasSeenReleaseChannelStories) { - return -1L; - } - - RecipientId releaseChannelRecipientId = SignalStore.releaseChannelValues().getReleaseChannelRecipientId(); - if (releaseChannelRecipientId == null) { - return -1L; - } - - Long releaseChannelThreadId = SignalDatabase.threads().getThreadIdFor(releaseChannelRecipientId); - if (releaseChannelThreadId == null) { - return -1L; - } - - return releaseChannelThreadId; - } - - @VisibleForTesting - public void deleteGroupStoryReplies(long parentStoryId) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - String[] args = SqlUtil.buildArgs(parentStoryId); - - db.delete(TABLE_NAME, PARENT_STORY_ID + " = ?", args); - } - - public int deleteStoriesOlderThan(long timestamp, boolean hasSeenReleaseChannelStories) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - db.beginTransaction(); - try { - long releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories); - String storiesBeforeTimestampWhere = IS_STORY_CLAUSE + " AND " + DATE_SENT + " < ? AND " + THREAD_ID + " != ?"; - String[] sharedArgs = SqlUtil.buildArgs(timestamp, releaseChannelThreadId); - String deleteStoryRepliesQuery = "DELETE FROM " + TABLE_NAME + " " + - "WHERE " + PARENT_STORY_ID + " > 0 AND " + PARENT_STORY_ID + " IN (" + - "SELECT " + ID + " " + - "FROM " + TABLE_NAME + " " + - "WHERE " + storiesBeforeTimestampWhere + - ")"; - String disassociateQuoteQuery = "UPDATE " + TABLE_NAME + " " + - "SET " + QUOTE_MISSING + " = 1, " + QUOTE_BODY + " = '' " + - "WHERE " + PARENT_STORY_ID + " < 0 AND ABS(" + PARENT_STORY_ID + ") IN (" + - "SELECT " + ID + " " + - "FROM " + TABLE_NAME + " " + - "WHERE " + storiesBeforeTimestampWhere + - ")"; - - db.execSQL(deleteStoryRepliesQuery, sharedArgs); - db.execSQL(disassociateQuoteQuery, sharedArgs); - - try (Cursor cursor = db.query(TABLE_NAME, new String[]{RECIPIENT_ID}, storiesBeforeTimestampWhere, sharedArgs, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))); - ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(recipientId); - } - } - - int deletedStoryCount; - try (Cursor cursor = db.query(TABLE_NAME, new String[]{ID}, storiesBeforeTimestampWhere, sharedArgs, null, null, null)) { - deletedStoryCount = cursor.getCount(); - - while (cursor.moveToNext()) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); - deleteMessage(id); - } - } - - if (deletedStoryCount > 0) { - OptimizeMessageSearchIndexJob.enqueue(); - } - - db.setTransactionSuccessful(); - return deletedStoryCount; - } finally { - db.endTransaction(); - } - } - - private void disassociateStoryQuotes(long storyId) { - ContentValues contentValues = new ContentValues(2); - contentValues.put(QUOTE_MISSING, 1); - contentValues.putNull(QUOTE_BODY); - - getWritableDatabase().update(TABLE_NAME, - contentValues, - PARENT_STORY_ID + " = ?", - SqlUtil.buildArgs(new ParentStoryId.DirectReply(storyId).serialize())); - } - - public boolean isGroupQuitMessage(long messageId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - String[] columns = new String[]{ID}; - long type = MessageTypes.getOutgoingEncryptedMessageType() | MessageTypes.GROUP_LEAVE_BIT; - String query = ID + " = ? AND " + TYPE + " & " + type + " = " + type + " AND " + TYPE + " & " + MessageTypes.GROUP_V2_BIT + " = 0"; - String[] args = SqlUtil.buildArgs(messageId); - - try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, null, null)) { - if (cursor.getCount() == 1) { - return true; - } - } - - return false; - } - - public long getLatestGroupQuitTimestamp(long threadId, long quitTimeBarrier) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - String[] columns = new String[]{DATE_SENT}; - long type = MessageTypes.getOutgoingEncryptedMessageType() | MessageTypes.GROUP_LEAVE_BIT; - String query = THREAD_ID + " = ? AND " + TYPE + " & " + type + " = " + type + " AND " + TYPE + " & " + MessageTypes.GROUP_V2_BIT + " = 0 AND " + DATE_SENT + " < ?"; - String[] args = new String[]{String.valueOf(threadId), String.valueOf(quitTimeBarrier)}; - String orderBy = DATE_SENT + " DESC"; - String limit = "1"; - - try (Cursor cursor = db.query(TABLE_NAME, columns, query, args, null, null, orderBy, limit)) { - if (cursor.moveToFirst()) { - return CursorUtil.requireLong(cursor, DATE_SENT); - } - } - - return -1; - } - - public int getScheduledMessageCountForThread(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?"; - String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1); - - try (Cursor cursor = db.query(TABLE_NAME + " INDEXED BY " + INDEX_THREAD_STORY_SCHEDULED_DATE, COUNT, query, args, null, null, null)) { - if (cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - public int getMessageCountForThread(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?"; - String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1); - - try (Cursor cursor = db.query(TABLE_NAME + " INDEXED BY " + INDEX_THREAD_STORY_SCHEDULED_DATE, COUNT, query, args, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - public int getMessageCountForThread(long threadId, long beforeTime) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?"; - String[] args = SqlUtil.buildArgs(threadId, beforeTime, 0, 0, -1); - - try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - public boolean hasMeaningfulMessage(long threadId) { - if (threadId == -1) { - return false; - } - - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - SqlUtil.Query query = buildMeaningfulMessagesQuery(threadId); - - try (Cursor cursor = db.query(TABLE_NAME, new String[] { "1" }, query.getWhere(), query.getWhereArgs(), null, null, null, "1")) { - return cursor != null && cursor.moveToFirst(); - } - } - - public int getIncomingMeaningfulMessageCountSince(long threadId, long afterTime) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] projection = SqlUtil.COUNT; - SqlUtil.Query meaningfulMessagesQuery = buildMeaningfulMessagesQuery(threadId); - String where = meaningfulMessagesQuery.getWhere() + " AND " + DATE_RECEIVED + " >= ? AND NOT (" + getOutgoingTypeClause() + ")"; - String[] whereArgs = SqlUtil.appendArg(meaningfulMessagesQuery.getWhereArgs(), String.valueOf(afterTime)); - - try (Cursor cursor = db.query(TABLE_NAME, projection, where, whereArgs, null, null, null, "1")) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } - } - - private @NonNull SqlUtil.Query buildMeaningfulMessagesQuery(long threadId) { - String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + "(NOT " + TYPE + " & ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + ")"; - return SqlUtil.buildQuery(query, threadId, 0, 0, MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING, MessageTypes.PROFILE_CHANGE_TYPE, MessageTypes.CHANGE_NUMBER_TYPE, MessageTypes.SMS_EXPORT_TYPE, MessageTypes.BOOST_REQUEST_TYPE); - } - - public void addFailures(long messageId, List failure) { - try { - addToDocument(messageId, NETWORK_FAILURES, failure, NetworkFailureSet.class); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - public void setNetworkFailures(long messageId, Set failures) { - try { - setDocument(databaseHelper.getSignalWritableDatabase(), messageId, NETWORK_FAILURES, new NetworkFailureSet(failures)); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - public long getThreadIdForMessage(long id) { - String sql = "SELECT " + THREAD_ID + " FROM " + TABLE_NAME + " WHERE " + ID + " = ?"; - String[] sqlArgs = new String[] {id+""}; - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - Cursor cursor = null; - - try { - cursor = db.rawQuery(sql, sqlArgs); - if (cursor != null && cursor.moveToFirst()) - return cursor.getLong(0); - else - return -1; - } finally { - if (cursor != null) - cursor.close(); - } - } - - private long getThreadIdFor(@NonNull IncomingMediaMessage retrieved) { - if (retrieved.getGroupId() != null) { - RecipientId groupRecipientId = SignalDatabase.recipients().getOrInsertFromPossiblyMigratedGroupId(retrieved.getGroupId()); - Recipient groupRecipients = Recipient.resolved(groupRecipientId); - return SignalDatabase.threads().getOrCreateThreadIdFor(groupRecipients); - } else { - Recipient sender = Recipient.resolved(retrieved.getFrom()); - return SignalDatabase.threads().getOrCreateThreadIdFor(sender); - } - } - - private long getThreadIdFor(@NonNull NotificationInd notification) { - String fromString = notification.getFrom() != null && notification.getFrom().getTextString() != null - ? Util.toIsoString(notification.getFrom().getTextString()) - : ""; - Recipient recipient = Recipient.external(context, fromString); - return SignalDatabase.threads().getOrCreateThreadIdFor(recipient); - } - - private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments) { - return rawQuery(where, arguments, false, 0); - } - - private Cursor rawQuery(@NonNull String where, @Nullable String[] arguments, boolean reverse, long limit) { - return rawQuery(MMS_PROJECTION_WITH_ATTACHMENTS, where, arguments, reverse, limit); - } - - private Cursor rawQuery(@NonNull String[] projection, @NonNull String where, @Nullable String[] arguments, boolean reverse, long limit) { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - String rawQueryString = "SELECT " + Util.join(projection, ",") + - " FROM " + MessageTable.TABLE_NAME + " LEFT OUTER JOIN " + AttachmentTable.TABLE_NAME + - " ON (" + MessageTable.TABLE_NAME + "." + MessageTable.ID + " = " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.MMS_ID + ")" + - " WHERE " + where + " GROUP BY " + MessageTable.TABLE_NAME + "." + MessageTable.ID; - - if (reverse) { - rawQueryString += " ORDER BY " + MessageTable.TABLE_NAME + "." + MessageTable.ID + " DESC"; - } - - if (limit > 0) { - rawQueryString += " LIMIT " + limit; - } - - return database.rawQuery(rawQueryString, arguments); - } - - private Cursor internalGetMessage(long messageId) { - return rawQuery(RAW_ID_WHERE, new String[] {messageId + ""}); - } - - public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException { - try (Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""})) { - MessageRecord record = new MmsReader(cursor).getNext(); - - if (record == null) { - throw new NoSuchMessageException("No message for ID: " + messageId); - } - - return record; - } - } - - public @Nullable MessageRecord getMessageRecordOrNull(long messageId) { - try (Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""})) { - return new MmsReader(cursor).getNext(); - } - } - - public MmsReader getMessages(Collection messageIds) { - String ids = TextUtils.join(",", messageIds); - return mmsReaderFor(rawQuery(MessageTable.TABLE_NAME + "." + MessageTable.ID + " IN (" + ids + ")", null)); - } - - private void updateMailboxBitmask(long id, long maskOff, long maskOn, Optional threadId) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - db.beginTransaction(); - try { - db.execSQL("UPDATE " + TABLE_NAME + - " SET " + TYPE + " = (" + TYPE + " & " + (MessageTypes.TOTAL_MASK - maskOff) + " | " + maskOn + " )" + - " WHERE " + ID + " = ?", new String[] { id + "" }); - - if (threadId.isPresent()) { - SignalDatabase.threads().updateSnippetTypeSilently(threadId.get()); - } - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - } - - public void markAsOutbox(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_OUTBOX_TYPE, Optional.of(threadId)); - } - - public void markAsForcedSms(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, MessageTypes.PUSH_MESSAGE_BIT, MessageTypes.MESSAGE_FORCE_SMS_BIT, Optional.of(threadId)); - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - } - - public void markAsRateLimited(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, 0, MessageTypes.MESSAGE_RATE_LIMITED_BIT, Optional.of(threadId)); - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - } - - public void clearRateLimitStatus(@NonNull Collection ids) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - db.beginTransaction(); - try { - for (long id : ids) { - long threadId = getThreadIdForMessage(id); - updateMailboxBitmask(id, MessageTypes.MESSAGE_RATE_LIMITED_BIT, 0, Optional.of(threadId)); - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - } - - public void markAsPendingInsecureSmsFallback(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK, Optional.of(threadId)); - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - } - - public void markAsSending(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_SENDING_TYPE, Optional.of(threadId)); - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); - } - - public void markAsSentFailed(long messageId) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_SENT_FAILED_TYPE, Optional.of(threadId)); - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); - } - - public void markAsSent(long messageId, boolean secure) { - long threadId = getThreadIdForMessage(messageId); - updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_SENT_TYPE | (secure ? MessageTypes.PUSH_MESSAGE_BIT | MessageTypes.SECURE_MESSAGE_BIT : 0), Optional.of(threadId)); - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); - } - - public void markAsRemoteDelete(long messageId) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - - long threadId; - - boolean deletedAttachments = false; - - db.beginTransaction(); - try { - ContentValues values = new ContentValues(); - values.put(REMOTE_DELETED, 1); - values.putNull(BODY); - values.putNull(QUOTE_BODY); - values.putNull(QUOTE_AUTHOR); - values.putNull(QUOTE_TYPE); - values.putNull(QUOTE_ID); - values.putNull(LINK_PREVIEWS); - values.putNull(SHARED_CONTACTS); - db.update(TABLE_NAME, values, ID_WHERE, new String[] { String.valueOf(messageId) }); - - deletedAttachments = SignalDatabase.attachments().deleteAttachmentsForMessage(messageId); - SignalDatabase.mentions().deleteMentionsForMessage(messageId); - SignalDatabase.messageLog().deleteAllRelatedToMessage(messageId); - SignalDatabase.reactions().deleteReactions(new MessageId(messageId)); - deleteGroupStoryReplies(messageId); - disassociateStoryQuotes(messageId); - - threadId = getThreadIdForMessage(messageId); - SignalDatabase.threads().update(threadId, false); - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - OptimizeMessageSearchIndexJob.enqueue(); - - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners(); - - if (deletedAttachments) { - ApplicationDependencies.getDatabaseObserver().notifyAttachmentObservers(); - } - } - - public void markDownloadState(long messageId, long state) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - ContentValues contentValues = new ContentValues(); - contentValues.put(MMS_STATUS, state); - - database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {messageId + ""}); - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - } - - public boolean clearScheduledStatus(long threadId, long messageId, long expiresIn) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - ContentValues contentValues = new ContentValues(); - contentValues.put(SCHEDULED_DATE, -1); - contentValues.put(DATE_SENT, System.currentTimeMillis()); - contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); - contentValues.put(EXPIRES_IN, expiresIn); - - int rowsUpdated = database.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1)); - ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId)); - ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId); - - return rowsUpdated > 0; - } - - public void rescheduleMessage(long threadId, long messageId, long time) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - ContentValues contentValues = new ContentValues(); - contentValues.put(SCHEDULED_DATE, time); - - int rowsUpdated = database.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1)); - ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId); - ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary(); - if (rowsUpdated == 0) { - Log.w(TAG, "Failed to reschedule messageId=" + messageId + " to new time " + time + ". may have been sent already"); - } - } - - public void markAsInsecure(long messageId) { - updateMailboxBitmask(messageId, MessageTypes.SECURE_MESSAGE_BIT, 0, Optional.empty()); - } - - public void markUnidentified(long messageId, boolean unidentified) { - ContentValues contentValues = new ContentValues(); - contentValues.put(UNIDENTIFIED, unidentified ? 1 : 0); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - } - - public void markExpireStarted(long id) { - markExpireStarted(id, System.currentTimeMillis()); - } - - public void markExpireStarted(long id, long startedTimestamp) { - markExpireStarted(Collections.singleton(id), startedTimestamp); - } - - public void markExpireStarted(Collection ids, long startedAtTimestamp) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - long threadId = -1; - - db.beginTransaction(); - try { - String query = ID + " = ? AND (" + EXPIRE_STARTED + " = 0 OR " + EXPIRE_STARTED + " > ?)"; - - for (long id : ids) { - ContentValues contentValues = new ContentValues(); - contentValues.put(EXPIRE_STARTED, startedAtTimestamp); - - db.update(TABLE_NAME, contentValues, query, new String[]{String.valueOf(id), String.valueOf(startedAtTimestamp)}); - - if (threadId < 0) { - threadId = getThreadIdForMessage(id); - } - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - SignalDatabase.threads().update(threadId, false); - notifyConversationListeners(threadId); - } - - public void markAsNotified(long id) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - ContentValues contentValues = new ContentValues(); - - contentValues.put(NOTIFIED, 1); - contentValues.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); - - database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)}); - } - - public List setMessagesReadSince(long threadId, long sinceTimestamp) { - if (sinceTimestamp == -1) { - return setMessagesRead(THREAD_ID + " = ? AND " + STORY_TYPE + " = 0 AND " + PARENT_STORY_ID + " <= 0 AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND (" + getOutgoingTypeClause() + ")))", new String[] { String.valueOf(threadId)}); - } else { - return setMessagesRead(THREAD_ID + " = ? AND " + STORY_TYPE + " = 0 AND " + PARENT_STORY_ID + " <= 0 AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND ( " + getOutgoingTypeClause() + " ))) AND " + DATE_RECEIVED + " <= ?", new String[]{ String.valueOf(threadId), String.valueOf(sinceTimestamp)}); - } - } - - public @NonNull List setGroupStoryMessagesReadSince(long threadId, long groupStoryId, long sinceTimestamp) { - if (sinceTimestamp == -1) { - return setMessagesRead(THREAD_ID + " = ? AND " + STORY_TYPE + " = 0 AND " + PARENT_STORY_ID + " = ? AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND (" + getOutgoingTypeClause() + ")))", SqlUtil.buildArgs(threadId, groupStoryId)); - } else { - return setMessagesRead(THREAD_ID + " = ? AND " + STORY_TYPE + " = 0 AND " + PARENT_STORY_ID + " = ? AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND ( " + getOutgoingTypeClause() + " ))) AND " + DATE_RECEIVED + " <= ?", SqlUtil.buildArgs(threadId, groupStoryId, sinceTimestamp)); - } - } - - public @NonNull List getStoryTypes(@NonNull List messageIds) { - List mmsMessages = messageIds.stream() - .map(MessageId::getId) - .collect(java.util.stream.Collectors.toList()); - - if (mmsMessages.isEmpty()) { - return Collections.emptyList(); - } - - String[] projection = SqlUtil.buildArgs(ID, STORY_TYPE); - List queries = SqlUtil.buildCollectionQuery(ID, mmsMessages); - HashMap storyTypes = new HashMap<>(); - - for (final SqlUtil.Query query : queries) { - try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, projection, query.getWhere(), query.getWhereArgs(), null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - storyTypes.put(CursorUtil.requireLong(cursor, ID), StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE))); - } - } - } - - return messageIds.stream().map(id -> { - if (storyTypes.containsKey(id.getId())) { - return storyTypes.get(id.getId()); - } else { - return StoryType.NONE; - } - }).collect(java.util.stream.Collectors.toList()); - } - - public List setEntireThreadRead(long threadId) { - return setMessagesRead(THREAD_ID + " = ? AND " + STORY_TYPE + " = 0 AND " + PARENT_STORY_ID + " <= 0", new String[] { String.valueOf(threadId)}); - } - - public List setAllMessagesRead() { - return setMessagesRead(STORY_TYPE + " = 0 AND " + PARENT_STORY_ID + " <= 0 AND (" + READ + " = 0 OR (" + REACTIONS_UNREAD + " = 1 AND (" + getOutgoingTypeClause() + ")))", null); - } - - private List setMessagesRead(String where, String[] arguments) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - List result = new LinkedList<>(); - Cursor cursor = null; - RecipientId releaseChannelId = SignalStore.releaseChannelValues().getReleaseChannelRecipientId(); - - database.beginTransaction(); - - try { - cursor = database.query(TABLE_NAME + " INDEXED BY " + INDEX_THREAD_DATE, new String[] { ID, RECIPIENT_ID, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED, THREAD_ID, STORY_TYPE }, where, arguments, null, null, null); - - while(cursor != null && cursor.moveToNext()) { - if (MessageTypes.isSecureType(CursorUtil.requireLong(cursor, TYPE))) { - long threadId = CursorUtil.requireLong(cursor, THREAD_ID); - RecipientId recipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); - long dateSent = CursorUtil.requireLong(cursor, DATE_SENT); - long messageId = CursorUtil.requireLong(cursor, ID); - long expiresIn = CursorUtil.requireLong(cursor, EXPIRES_IN); - long expireStarted = CursorUtil.requireLong(cursor, EXPIRE_STARTED); - SyncMessageId syncMessageId = new SyncMessageId(recipientId, dateSent); - ExpirationInfo expirationInfo = new ExpirationInfo(messageId, expiresIn, expireStarted, true); - StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); - - if (!recipientId.equals(releaseChannelId)) { - result.add(new MarkedMessageInfo(threadId, syncMessageId, new MessageId(messageId), expirationInfo, storyType)); - } - } - } - - ContentValues contentValues = new ContentValues(); - contentValues.put(READ, 1); - contentValues.put(REACTIONS_UNREAD, 0); - contentValues.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); - - database.update(TABLE_NAME + " INDEXED BY " + INDEX_THREAD_DATE, contentValues, where, arguments); - database.setTransactionSuccessful(); - } finally { - if (cursor != null) cursor.close(); - database.endTransaction(); - } - - return result; - } - - public @Nullable Pair getOldestUnreadMentionDetails(long threadId) { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[]{RECIPIENT_ID,DATE_RECEIVED}; - String selection = THREAD_ID + " = ? AND " + READ + " = 0 AND " + MENTIONS_SELF + " = 1"; - String[] args = SqlUtil.buildArgs(threadId); - - try (Cursor cursor = database.query(TABLE_NAME, projection, selection, args, null, null, DATE_RECEIVED + " ASC", "1")) { - if (cursor != null && cursor.moveToFirst()) { - return new Pair<>(RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)), CursorUtil.requireLong(cursor, DATE_RECEIVED)); - } - } - - return null; - } - - public int getUnreadMentionCount(long threadId) { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[]{"COUNT(*)"}; - String selection = THREAD_ID + " = ? AND " + READ + " = 0 AND " + MENTIONS_SELF + " = 1"; - String[] args = SqlUtil.buildArgs(threadId); - - try (Cursor cursor = database.query(TABLE_NAME, projection, selection, args, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - /** - * Trims data related to expired messages. Only intended to be run after a backup restore. - */ - void trimEntriesForExpiredMessages() { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - String trimmedCondition = " NOT IN (SELECT " + MessageTable.ID + " FROM " + MessageTable.TABLE_NAME + ")"; - - database.delete(GroupReceiptTable.TABLE_NAME, GroupReceiptTable.MMS_ID + trimmedCondition, null); - - String[] columns = new String[] { AttachmentTable.ROW_ID, AttachmentTable.UNIQUE_ID }; - String where = AttachmentTable.MMS_ID + trimmedCondition; - - try (Cursor cursor = database.query(AttachmentTable.TABLE_NAME, columns, where, null, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - SignalDatabase.attachments().deleteAttachment(new AttachmentId(cursor.getLong(0), cursor.getLong(1))); - } - } - - SignalDatabase.mentions().deleteAbandonedMentions(); - - try (Cursor cursor = database.query(ThreadTable.TABLE_NAME, new String[] { ThreadTable.ID }, ThreadTable.EXPIRES_IN + " > 0", null, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - SignalDatabase.threads().setLastScrolled(cursor.getLong(0), 0); - SignalDatabase.threads().update(cursor.getLong(0), false); - } - } - } - - public Optional getNotification(long messageId) { - Cursor cursor = null; - - try { - cursor = rawQuery(RAW_ID_WHERE, new String[] {String.valueOf(messageId)}); - - if (cursor != null && cursor.moveToNext()) { - return Optional.of(new MmsNotificationInfo(RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))), - cursor.getString(cursor.getColumnIndexOrThrow(MMS_CONTENT_LOCATION)), - cursor.getString(cursor.getColumnIndexOrThrow(MMS_TRANSACTION_ID)), - cursor.getInt(cursor.getColumnIndexOrThrow(SMS_SUBSCRIPTION_ID)))); - } else { - return Optional.empty(); - } - } finally { - if (cursor != null) - cursor.close(); - } - } - - public OutgoingMessage getOutgoingMessage(long messageId) - throws MmsException, NoSuchMessageException - { - AttachmentTable attachmentDatabase = SignalDatabase.attachments(); - MentionTable mentionDatabase = SignalDatabase.mentions(); - Cursor cursor = null; - - try { - cursor = rawQuery(RAW_ID_WHERE, new String[] {String.valueOf(messageId)}); - - if (cursor != null && cursor.moveToNext()) { - List associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId); - List mentions = mentionDatabase.getMentionsForMessage(messageId); - - long outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(TYPE)); - String body = cursor.getString(cursor.getColumnIndexOrThrow(BODY)); - long timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_SENT)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SMS_SUBSCRIPTION_ID)); - long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)); - boolean viewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(VIEW_ONCE)) == 1; - long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); - int distributionType = SignalDatabase.threads().getDistributionType(threadId); - String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.MISMATCHED_IDENTITIES)); - String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.NETWORK_FAILURES)); - StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); - ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID)); - byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES); - long scheduledDate = cursor.getLong(cursor.getColumnIndexOrThrow(SCHEDULED_DATE)); - - long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)); - long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)); - String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY)); - int quoteType = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_TYPE)); - boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1; - List quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList(); - List quoteMentions = parseQuoteMentions(cursor); - BodyRangeList quoteBodyRanges = parseQuoteBodyRanges(cursor); - List contacts = getSharedContacts(cursor, associatedAttachments); - Set contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList()); - List previews = getLinkPreviews(cursor, associatedAttachments); - Set previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet()); - List attachments = Stream.of(associatedAttachments).filterNot(Attachment::isQuote) - .filterNot(contactAttachments::contains) - .filterNot(previewAttachments::contains) - .sorted(new DatabaseAttachment.DisplayOrderComparator()) - .map(a -> (Attachment)a).toList(); - - Recipient recipient = Recipient.resolved(RecipientId.from(recipientId)); - Set networkFailures = new HashSet<>(); - Set mismatches = new HashSet<>(); - QuoteModel quote = null; - - if (quoteId > 0 && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || !quoteAttachments.isEmpty())) { - quote = new QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteAttachments, quoteMentions, QuoteModel.Type.fromCode(quoteType), quoteBodyRanges); - } - - if (!TextUtils.isEmpty(mismatchDocument)) { - try { - mismatches = JsonUtils.fromJson(mismatchDocument, IdentityKeyMismatchSet.class).getItems(); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - if (!TextUtils.isEmpty(networkDocument)) { - try { - networkFailures = JsonUtils.fromJson(networkDocument, NetworkFailureSet.class).getItems(); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - if (body != null && (MessageTypes.isGroupQuit(outboxType) || MessageTypes.isGroupUpdate(outboxType))) { - return OutgoingMessage.groupUpdateMessage(recipient, new MessageGroupContext(body, MessageTypes.isGroupV2(outboxType)), attachments, timestamp, 0, false, quote, contacts, previews, mentions); - } else if (MessageTypes.isExpirationTimerUpdate(outboxType)) { - return OutgoingMessage.expirationUpdateMessage(recipient, timestamp, expiresIn); - } else if (MessageTypes.isPaymentsNotification(outboxType)) { - return OutgoingMessage.paymentNotificationMessage(recipient, Objects.requireNonNull(body), timestamp, expiresIn); - } else if (MessageTypes.isPaymentsRequestToActivate(outboxType)) { - return OutgoingMessage.requestToActivatePaymentsMessage(recipient, timestamp, expiresIn); - } else if (MessageTypes.isPaymentsActivated(outboxType)) { - return OutgoingMessage.paymentsActivatedMessage(recipient, timestamp, expiresIn); - } - - GiftBadge giftBadge = null; - if (body != null && MessageTypes.isGiftBadge(outboxType)) { - giftBadge = GiftBadge.parseFrom(Base64.decode(body)); - } - - BodyRangeList messageRanges = null; - if (messageRangesData != null) { - try { - messageRanges = BodyRangeList.parseFrom(messageRangesData); - } catch (InvalidProtocolBufferException e) { - Log.w(TAG, "Error parsing message ranges", e); - } - } - - OutgoingMessage message = new OutgoingMessage(recipient, - body, - attachments, - timestamp, - subscriptionId, - expiresIn, - viewOnce, - distributionType, - storyType, - parentStoryId, - MessageTypes.isStoryReaction(outboxType), - quote, - contacts, - previews, - mentions, - networkFailures, - mismatches, - giftBadge, - MessageTypes.isSecureType(outboxType), - messageRanges, - scheduledDate); - - return message; - } - - throw new NoSuchMessageException("No record found for id: " + messageId); - } catch (IOException e) { - throw new MmsException(e); - } finally { - if (cursor != null) - cursor.close(); - } - } - - private static List getSharedContacts(@NonNull Cursor cursor, @NonNull List attachments) { - String serializedContacts = cursor.getString(cursor.getColumnIndexOrThrow(SHARED_CONTACTS)); - - if (TextUtils.isEmpty(serializedContacts)) { - return Collections.emptyList(); - } - - Map attachmentIdMap = new HashMap<>(); - for (DatabaseAttachment attachment : attachments) { - attachmentIdMap.put(attachment.getAttachmentId(), attachment); - } - - try { - List contacts = new LinkedList<>(); - JSONArray jsonContacts = new JSONArray(serializedContacts); - - for (int i = 0; i < jsonContacts.length(); i++) { - Contact contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString()); - - if (contact.getAvatar() != null && contact.getAvatar().getAttachmentId() != null) { - DatabaseAttachment attachment = attachmentIdMap.get(contact.getAvatar().getAttachmentId()); - Avatar updatedAvatar = new Avatar(contact.getAvatar().getAttachmentId(), - attachment, - contact.getAvatar().isProfile()); - - contacts.add(new Contact(contact, updatedAvatar)); - } else { - contacts.add(contact); - } - } - - return contacts; - } catch (JSONException | IOException e) { - Log.w(TAG, "Failed to parse shared contacts.", e); - } - - return Collections.emptyList(); - } - - private static List getLinkPreviews(@NonNull Cursor cursor, @NonNull List attachments) { - String serializedPreviews = cursor.getString(cursor.getColumnIndexOrThrow(LINK_PREVIEWS)); - - if (TextUtils.isEmpty(serializedPreviews)) { - return Collections.emptyList(); - } - - Map attachmentIdMap = new HashMap<>(); - for (DatabaseAttachment attachment : attachments) { - attachmentIdMap.put(attachment.getAttachmentId(), attachment); - } - - try { - List previews = new LinkedList<>(); - JSONArray jsonPreviews = new JSONArray(serializedPreviews); - - for (int i = 0; i < jsonPreviews.length(); i++) { - LinkPreview preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString()); - - if (preview.getAttachmentId() != null) { - DatabaseAttachment attachment = attachmentIdMap.get(preview.getAttachmentId()); - if (attachment != null) { - previews.add(new LinkPreview(preview.getUrl(), preview.getTitle(), preview.getDescription(), preview.getDate(), attachment)); - } else { - previews.add(preview); - } - } else { - previews.add(preview); - } - } - - return previews; - } catch (JSONException | IOException e) { - Log.w(TAG, "Failed to parse shared contacts.", e); - } - - return Collections.emptyList(); - } - - private Optional insertMessageInbox(IncomingMediaMessage retrieved, - String contentLocation, - long threadId, long mailbox) - throws MmsException - { - if (threadId == -1 || retrieved.isGroupMessage()) { - threadId = getThreadIdFor(retrieved); - } - - ContentValues contentValues = new ContentValues(); - - boolean silentUpdate = (mailbox & MessageTypes.GROUP_UPDATE_BIT) > 0; - - contentValues.put(DATE_SENT, retrieved.getSentTimeMillis()); - contentValues.put(DATE_SERVER, retrieved.getServerTimeMillis()); - contentValues.put(RECIPIENT_ID, retrieved.getFrom().serialize()); - - contentValues.put(TYPE, mailbox); - contentValues.put(MMS_MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF); - contentValues.put(THREAD_ID, threadId); - contentValues.put(MMS_CONTENT_LOCATION, contentLocation); - contentValues.put(MMS_STATUS, MmsStatus.DOWNLOAD_INITIALIZED); - contentValues.put(DATE_RECEIVED, retrieved.isPushMessage() ? retrieved.getReceivedTimeMillis() : generatePduCompatTimestamp(retrieved.getReceivedTimeMillis())); - contentValues.put(SMS_SUBSCRIPTION_ID, retrieved.getSubscriptionId()); - contentValues.put(EXPIRES_IN, retrieved.getExpiresIn()); - contentValues.put(VIEW_ONCE, retrieved.isViewOnce() ? 1 : 0); - contentValues.put(STORY_TYPE, retrieved.getStoryType().getCode()); - contentValues.put(PARENT_STORY_ID, retrieved.getParentStoryId() != null ? retrieved.getParentStoryId().serialize() : 0); - contentValues.put(READ, (silentUpdate || retrieved.isExpirationUpdate()) ? 1 : 0); - contentValues.put(UNIDENTIFIED, retrieved.isUnidentified()); - contentValues.put(SERVER_GUID, retrieved.getServerGuid()); - - if (!contentValues.containsKey(DATE_SENT)) { - contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED)); - } - - List quoteAttachments = new LinkedList<>(); - - if (retrieved.getQuote() != null) { - contentValues.put(QUOTE_ID, retrieved.getQuote().getId()); - contentValues.put(QUOTE_BODY, retrieved.getQuote().getText()); - contentValues.put(QUOTE_AUTHOR, retrieved.getQuote().getAuthor().serialize()); - contentValues.put(QUOTE_TYPE, retrieved.getQuote().getType().getCode()); - contentValues.put(QUOTE_MISSING, retrieved.getQuote().isOriginalMissing() ? 1 : 0); - - BodyRangeList.Builder quoteBodyRanges = retrieved.getQuote().getBodyRanges() != null ? retrieved.getQuote().getBodyRanges().toBuilder() - : BodyRangeList.newBuilder(); - - BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(retrieved.getQuote().getMentions()); - if (mentionsList != null) { - quoteBodyRanges.addAllRanges(mentionsList.getRangesList()); - } - - if (quoteBodyRanges.getRangesCount() > 0) { - contentValues.put(QUOTE_BODY_RANGES, quoteBodyRanges.build().toByteArray()); - } - - quoteAttachments = retrieved.getQuote().getAttachments(); - } - - if (retrieved.isPushMessage() && isDuplicate(retrieved, threadId)) { - Log.w(TAG, "Ignoring duplicate media message (" + retrieved.getSentTimeMillis() + ")"); - return Optional.empty(); - } - - boolean updateThread = retrieved.getStoryType() == StoryType.NONE; - - RecipientId threadRecipientId = SignalDatabase.threads().getRecipientIdForThreadId(threadId); - if (threadRecipientId == null) { - threadRecipientId = retrieved.getFrom(); - } - long messageId = insertMediaMessage(threadId, - retrieved.getBody(), - retrieved.getAttachments(), - quoteAttachments, - retrieved.getSharedContacts(), - retrieved.getLinkPreviews(), - retrieved.getMentions(), - retrieved.getMessageRanges(), - contentValues, - null, - updateThread, - true); - - boolean isNotStoryGroupReply = retrieved.getParentStoryId() == null || !retrieved.getParentStoryId().isGroupReply(); - - if (!MessageTypes.isPaymentsActivated(mailbox) && !MessageTypes.isPaymentsRequestToActivate(mailbox) && !MessageTypes.isExpirationTimerUpdate(mailbox) && !retrieved.getStoryType().isStory() && isNotStoryGroupReply) { - boolean incrementUnreadMentions = !retrieved.getMentions().isEmpty() && retrieved.getMentions().stream().anyMatch(m -> m.getRecipientId().equals(Recipient.self().getId())); - SignalDatabase.threads().incrementUnread(threadId, 1, incrementUnreadMentions ? 1 : 0); - SignalDatabase.threads().update(threadId, true); - } - - notifyConversationListeners(threadId); - - if (retrieved.getStoryType().isStory()) { - ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(Objects.requireNonNull(SignalDatabase.threads().getRecipientIdForThreadId(threadId))); - } - - return Optional.of(new InsertResult(messageId, threadId)); - } - - public Optional insertMessageInbox(IncomingMediaMessage retrieved, - String contentLocation, long threadId) - throws MmsException - { - long type = MessageTypes.BASE_INBOX_TYPE; - - if (retrieved.isPushMessage()) { - type |= MessageTypes.PUSH_MESSAGE_BIT; - } - - if (retrieved.isExpirationUpdate()) { - type |= MessageTypes.EXPIRATION_TIMER_UPDATE_BIT; - } - - if (retrieved.isPaymentsNotification()) { - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION; - } - - if (retrieved.isActivatePaymentsRequest()) { - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST; - } - - if (retrieved.isPaymentsActivated()) { - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED; - } - - return insertMessageInbox(retrieved, contentLocation, threadId, type); - } - - public Optional insertSecureDecryptedMessageInbox(IncomingMediaMessage retrieved, long threadId) - throws MmsException - { - long type = MessageTypes.BASE_INBOX_TYPE | MessageTypes.SECURE_MESSAGE_BIT; - - if (retrieved.isPushMessage()) { - type |= MessageTypes.PUSH_MESSAGE_BIT; - } - - if (retrieved.isExpirationUpdate()) { - type |= MessageTypes.EXPIRATION_TIMER_UPDATE_BIT; - } - - boolean hasSpecialType = false; - if (retrieved.isStoryReaction()) { - hasSpecialType = true; - type |= MessageTypes.SPECIAL_TYPE_STORY_REACTION; - } - - if (retrieved.getGiftBadge() != null) { - if (hasSpecialType) { - throw new MmsException("Cannot insert message with multiple special types."); - } - - type |= MessageTypes.SPECIAL_TYPE_GIFT_BADGE; - } - - if (retrieved.isPaymentsNotification()) { - if (hasSpecialType) { - throw new MmsException("Cannot insert message with multiple special types."); - } - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION; - } - - if (retrieved.isActivatePaymentsRequest()) { - if (hasSpecialType) { - throw new MmsException("Cannot insert message with multiple special types."); - } - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST; - } - - if (retrieved.isPaymentsActivated()) { - if (hasSpecialType) { - throw new MmsException("Cannot insert message with multiple special types."); - } - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED; - } - - return insertMessageInbox(retrieved, "", threadId, type); - } - - public Pair insertMessageInbox(@NonNull NotificationInd notification, int subscriptionId) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - long threadId = getThreadIdFor(notification); - ContentValues contentValues = new ContentValues(); - ContentValuesBuilder contentBuilder = new ContentValuesBuilder(contentValues); - - Log.i(TAG, "Message received type: " + notification.getMessageType()); - - contentBuilder.add(MMS_CONTENT_LOCATION, notification.getContentLocation()); - contentBuilder.add(DATE_SENT, System.currentTimeMillis()); - contentBuilder.add(MMS_EXPIRY, notification.getExpiry()); - contentBuilder.add(MMS_MESSAGE_SIZE, notification.getMessageSize()); - contentBuilder.add(MMS_TRANSACTION_ID, notification.getTransactionId()); - contentBuilder.add(MMS_MESSAGE_TYPE, notification.getMessageType()); - - if (notification.getFrom() != null) { - Recipient recipient = Recipient.external(context, Util.toIsoString(notification.getFrom().getTextString())); - contentValues.put(RECIPIENT_ID, recipient.getId().serialize()); - } else { - contentValues.put(RECIPIENT_ID, RecipientId.UNKNOWN.serialize()); - } - - contentValues.put(TYPE, MessageTypes.BASE_INBOX_TYPE); - contentValues.put(THREAD_ID, threadId); - contentValues.put(MMS_STATUS, MmsStatus.DOWNLOAD_INITIALIZED); - contentValues.put(DATE_RECEIVED, generatePduCompatTimestamp(System.currentTimeMillis())); - contentValues.put(READ, Util.isDefaultSmsProvider(context) ? 0 : 1); - contentValues.put(SMS_SUBSCRIPTION_ID, subscriptionId); - - if (!contentValues.containsKey(DATE_SENT)) - contentValues.put(DATE_SENT, contentValues.getAsLong(DATE_RECEIVED)); - - long messageId = db.insert(TABLE_NAME, null, contentValues); - - return new Pair<>(messageId, threadId); - } - - public @NonNull InsertResult insertChatSessionRefreshedMessage(@NonNull RecipientId recipientId, long senderDeviceId, long sentTimestamp) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId)); - long type = MessageTypes.SECURE_MESSAGE_BIT | MessageTypes.PUSH_MESSAGE_BIT; - - type = type & (MessageTypes.TOTAL_MASK - MessageTypes.ENCRYPTION_MASK) | MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT; - - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, senderDeviceId); - values.put(DATE_RECEIVED, System.currentTimeMillis()); - values.put(DATE_SENT, sentTimestamp); - values.put(DATE_SERVER, -1); - values.put(READ, 0); - values.put(TYPE, type); - values.put(THREAD_ID, threadId); - - long messageId = db.insert(TABLE_NAME, null, values); - - SignalDatabase.threads().incrementUnread(threadId, 1, 0); - SignalDatabase.threads().update(threadId, true); - - notifyConversationListeners(threadId); - - TrimThreadJob.enqueueAsync(threadId); - - return new InsertResult(messageId, threadId); - } - - public void insertBadDecryptMessage(@NonNull RecipientId recipientId, int senderDevice, long sentTimestamp, long receivedTimestamp, long threadId) { - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, recipientId.serialize()); - values.put(RECIPIENT_DEVICE_ID, senderDevice); - values.put(DATE_SENT, sentTimestamp); - values.put(DATE_RECEIVED, receivedTimestamp); - values.put(DATE_SERVER, -1); - values.put(READ, 0); - values.put(TYPE, MessageTypes.BAD_DECRYPT_TYPE); - values.put(THREAD_ID, threadId); - - databaseHelper.getSignalWritableDatabase().insert(TABLE_NAME, null, values); - - SignalDatabase.threads().incrementUnread(threadId, 1, 0); - SignalDatabase.threads().update(threadId, true); - - notifyConversationListeners(threadId); - - TrimThreadJob.enqueueAsync(threadId); - } - - public void markIncomingNotificationReceived(long threadId) { - notifyConversationListeners(threadId); - - if (org.thoughtcrime.securesms.util.Util.isDefaultSmsProvider(context)) { - SignalDatabase.threads().incrementUnread(threadId, 1, 0); - } - - SignalDatabase.threads().update(threadId, true); - - TrimThreadJob.enqueueAsync(threadId); - } - - public void markGiftRedemptionCompleted(long messageId) { - markGiftRedemptionState(messageId, GiftBadge.RedemptionState.REDEEMED); - } - - public void markGiftRedemptionStarted(long messageId) { - markGiftRedemptionState(messageId, GiftBadge.RedemptionState.STARTED); - } - - public void markGiftRedemptionFailed(long messageId) { - markGiftRedemptionState(messageId, GiftBadge.RedemptionState.FAILED); - } - - private void markGiftRedemptionState(long messageId, @NonNull GiftBadge.RedemptionState redemptionState) { - String[] projection = SqlUtil.buildArgs(BODY, THREAD_ID); - String where = "(" + TYPE + " & " + MessageTypes.SPECIAL_TYPES_MASK + " = " + MessageTypes.SPECIAL_TYPE_GIFT_BADGE + ") AND " + - ID + " = ?"; - String[] args = SqlUtil.buildArgs(messageId); - boolean updated = false; - long threadId = -1; - - getWritableDatabase().beginTransaction(); - try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, projection, where, args, null, null, null)) { - if (cursor.moveToFirst()) { - GiftBadge giftBadge = GiftBadge.parseFrom(Base64.decode(CursorUtil.requireString(cursor, BODY))); - GiftBadge updatedBadge = giftBadge.toBuilder().setRedemptionState(redemptionState).build(); - ContentValues contentValues = new ContentValues(1); - - contentValues.put(BODY, Base64.encodeBytes(updatedBadge.toByteArray())); - - updated = getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, args) > 0; - threadId = CursorUtil.requireLong(cursor, THREAD_ID); - - getWritableDatabase().setTransactionSuccessful(); - } - } catch (IOException e) { - Log.w(TAG, "Failed to mark gift badge " + redemptionState.name(), e, true); - } finally { - getWritableDatabase().endTransaction(); - } - - if (updated) { - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); - notifyConversationListeners(threadId); - } - } - - public long insertMessageOutbox(@NonNull OutgoingMessage message, - long threadId, - boolean forceSms, - @Nullable InsertListener insertListener) - throws MmsException - { - return insertMessageOutbox(message, threadId, forceSms, GroupReceiptTable.STATUS_UNDELIVERED, insertListener); - } - - public long insertMessageOutbox(@NonNull OutgoingMessage message, - long threadId, boolean forceSms, int defaultReceiptStatus, - @Nullable InsertListener insertListener) - throws MmsException - { - long type = MessageTypes.BASE_SENDING_TYPE; - - if (message.isSecure()) type |= (MessageTypes.SECURE_MESSAGE_BIT | MessageTypes.PUSH_MESSAGE_BIT); - if (forceSms) type |= MessageTypes.MESSAGE_FORCE_SMS_BIT; - - if (message.isSecure()) type |= (MessageTypes.SECURE_MESSAGE_BIT | MessageTypes.PUSH_MESSAGE_BIT); - else if (message.isEndSession()) type |= MessageTypes.END_SESSION_BIT; - - if (message.isIdentityVerified()) type |= MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT; - else if (message.isIdentityDefault()) type |= MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT; - - if (message.isGroup()) { - if (message.isV2Group()) { - type |= MessageTypes.GROUP_V2_BIT | MessageTypes.GROUP_UPDATE_BIT; - if (message.isJustAGroupLeave()) { - type |= MessageTypes.GROUP_LEAVE_BIT; - } - } else { - MessageGroupContext.GroupV1Properties properties = message.requireGroupV1Properties(); - if (properties.isUpdate()) type |= MessageTypes.GROUP_UPDATE_BIT; - else if (properties.isQuit()) type |= MessageTypes.GROUP_LEAVE_BIT; - } - } - - if (message.isExpirationUpdate()) { - type |= MessageTypes.EXPIRATION_TIMER_UPDATE_BIT; - } - - boolean hasSpecialType = false; - if (message.isStoryReaction()) { - hasSpecialType = true; - type |= MessageTypes.SPECIAL_TYPE_STORY_REACTION; - } - - if (message.getGiftBadge() != null) { - if (hasSpecialType) { - throw new MmsException("Cannot insert message with multiple special types."); - } - - type |= MessageTypes.SPECIAL_TYPE_GIFT_BADGE; - } - - if (message.isPaymentsNotification()) { - if (hasSpecialType) { - throw new MmsException("Cannot insert message with multiple special types."); - } - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION; - } - - if (message.isRequestToActivatePayments()) { - if (hasSpecialType) { - throw new MmsException("Cannot insert message with multiple special types."); - } - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST; - } - - if (message.isPaymentsActivated()) { - if (hasSpecialType) { - throw new MmsException("Cannot insert message with multiple special types."); - } - type |= MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED; - } - - Map earlyDeliveryReceipts = earlyDeliveryReceiptCache.remove(message.getSentTimeMillis()); - - if (earlyDeliveryReceipts.size() > 0) { - Log.w(TAG, "Found early delivery receipts for " + message.getSentTimeMillis() + ". Applying them."); - } - - ContentValues contentValues = new ContentValues(); - contentValues.put(DATE_SENT, message.getSentTimeMillis()); - contentValues.put(MMS_MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ); - - contentValues.put(TYPE, type); - contentValues.put(THREAD_ID, threadId); - contentValues.put(READ, 1); - contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); - contentValues.put(SMS_SUBSCRIPTION_ID, message.getSubscriptionId()); - contentValues.put(EXPIRES_IN, message.getExpiresIn()); - contentValues.put(VIEW_ONCE, message.isViewOnce()); - contentValues.put(RECIPIENT_ID, message.getRecipient().getId().serialize()); - contentValues.put(DELIVERY_RECEIPT_COUNT, Stream.of(earlyDeliveryReceipts.values()).mapToLong(EarlyReceiptCache.Receipt::getCount).sum()); - contentValues.put(RECEIPT_TIMESTAMP, Stream.of(earlyDeliveryReceipts.values()).mapToLong(EarlyReceiptCache.Receipt::getTimestamp).max().orElse(-1)); - contentValues.put(STORY_TYPE, message.getStoryType().getCode()); - contentValues.put(PARENT_STORY_ID, message.getParentStoryId() != null ? message.getParentStoryId().serialize() : 0); - contentValues.put(SCHEDULED_DATE, message.getScheduledDate()); - - if (message.getRecipient().isSelf() && hasAudioAttachment(message.getAttachments())) { - contentValues.put(VIEWED_RECEIPT_COUNT, 1L); - } - - List quoteAttachments = new LinkedList<>(); - - if (message.getOutgoingQuote() != null) { - MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getOutgoingQuote().getText(), message.getOutgoingQuote().getMentions()); - - contentValues.put(QUOTE_ID, message.getOutgoingQuote().getId()); - contentValues.put(QUOTE_AUTHOR, message.getOutgoingQuote().getAuthor().serialize()); - contentValues.put(QUOTE_BODY, updated.getBodyAsString()); - contentValues.put(QUOTE_TYPE, message.getOutgoingQuote().getType().getCode()); - contentValues.put(QUOTE_MISSING, message.getOutgoingQuote().isOriginalMissing() ? 1 : 0); - - BodyRangeList adjustedQuoteBodyRanges = BodyRangeUtil.adjustBodyRanges(message.getOutgoingQuote().getBodyRanges(), updated.getBodyAdjustments()); - BodyRangeList.Builder quoteBodyRanges; - if (adjustedQuoteBodyRanges != null) { - quoteBodyRanges = adjustedQuoteBodyRanges.toBuilder(); - } else { - quoteBodyRanges = BodyRangeList.newBuilder(); - } - - BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(updated.getMentions()); - if (mentionsList != null) { - quoteBodyRanges.addAllRanges(mentionsList.getRangesList()); - } - - if (quoteBodyRanges.getRangesCount() > 0) { - contentValues.put(QUOTE_BODY_RANGES, quoteBodyRanges.build().toByteArray()); - } - - quoteAttachments.addAll(message.getOutgoingQuote().getAttachments()); - } - - MentionUtil.UpdatedBodyAndMentions updatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getBody(), message.getMentions()); - BodyRangeList bodyRanges = BodyRangeUtil.adjustBodyRanges(message.getBodyRanges(), updatedBodyAndMentions.getBodyAdjustments()); - - long messageId = insertMediaMessage(threadId, updatedBodyAndMentions.getBodyAsString(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), updatedBodyAndMentions.getMentions(), bodyRanges, contentValues, insertListener, false, false); - - if (message.getRecipient().isGroup()) { - GroupReceiptTable receiptDatabase = SignalDatabase.groupReceipts(); - Set members = new HashSet<>(); - - if (message.isGroupUpdate() && message.isV2Group()) { - MessageGroupContext.GroupV2Properties groupV2Properties = message.requireGroupV2Properties(); - members.addAll(Stream.of(groupV2Properties.getAllActivePendingAndRemovedMembers()) - .distinct() - .map(uuid -> RecipientId.from(ServiceId.from(uuid))) - .toList()); - members.remove(Recipient.self().getId()); - } else { - members.addAll(Stream.of(SignalDatabase.groups().getGroupMembers(message.getRecipient().requireGroupId(), GroupTable.MemberSet.FULL_MEMBERS_EXCLUDING_SELF)).map(Recipient::getId).toList()); - } - - receiptDatabase.insert(members, messageId, defaultReceiptStatus, message.getSentTimeMillis()); - - for (RecipientId recipientId : earlyDeliveryReceipts.keySet()) { - receiptDatabase.update(recipientId, messageId, GroupReceiptTable.STATUS_DELIVERED, -1); - } - } else if (message.getRecipient().isDistributionList()) { - GroupReceiptTable receiptDatabase = SignalDatabase.groupReceipts(); - List members = SignalDatabase.distributionLists().getMembers(message.getRecipient().requireDistributionListId()); - - receiptDatabase.insert(members, messageId, defaultReceiptStatus, message.getSentTimeMillis()); - - for (RecipientId recipientId : earlyDeliveryReceipts.keySet()) { - receiptDatabase.update(recipientId, messageId, GroupReceiptTable.STATUS_DELIVERED, -1); - } - } - - SignalDatabase.threads().updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId); - - if (!message.getStoryType().isStory()) { - if (message.getOutgoingQuote() == null) { - ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId)); - } else { - ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId); - } - if (message.getScheduledDate() != -1) { - ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId); - } - } else { - ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(message.getRecipient().getId()); - } - - notifyConversationListListeners(); - - if (!message.isIdentityVerified() && !message.isIdentityDefault()) { - TrimThreadJob.enqueueAsync(threadId); - } - - return messageId; - } - - private boolean hasAudioAttachment(@NonNull List attachments) { - for (Attachment attachment : attachments) { - if (MediaUtil.isAudio(attachment)) { - return true; - } - } - - return false; - } - - private long insertMediaMessage(long threadId, - @Nullable String body, - @NonNull List attachments, - @NonNull List quoteAttachments, - @NonNull List sharedContacts, - @NonNull List linkPreviews, - @NonNull List mentions, - @Nullable BodyRangeList messageRanges, - @NonNull ContentValues contentValues, - @Nullable InsertListener insertListener, - boolean updateThread, - boolean unarchive) - throws MmsException - { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - AttachmentTable partsDatabase = SignalDatabase.attachments(); - MentionTable mentionDatabase = SignalDatabase.mentions(); - - boolean mentionsSelf = Stream.of(mentions).filter(m -> Recipient.resolved(m.getRecipientId()).isSelf()).findFirst().isPresent(); - - List allAttachments = new LinkedList<>(); - List contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList(); - List previewAttachments = Stream.of(linkPreviews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).toList(); - - allAttachments.addAll(attachments); - allAttachments.addAll(contactAttachments); - allAttachments.addAll(previewAttachments); - - contentValues.put(BODY, body); - contentValues.put(MENTIONS_SELF, mentionsSelf ? 1 : 0); - - if (messageRanges != null) { - contentValues.put(MESSAGE_RANGES, messageRanges.toByteArray()); - } - - db.beginTransaction(); - try { - long messageId = db.insert(TABLE_NAME, null, contentValues); - - mentionDatabase.insert(threadId, messageId, mentions); - - Map insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments); - String serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts); - String serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews); - - if (!TextUtils.isEmpty(serializedContacts)) { - ContentValues contactValues = new ContentValues(); - contactValues.put(SHARED_CONTACTS, serializedContacts); - - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - int rows = database.update(TABLE_NAME, contactValues, ID + " = ?", new String[]{ String.valueOf(messageId) }); - - if (rows <= 0) { - Log.w(TAG, "Failed to update message with shared contact data."); - } - } - - if (!TextUtils.isEmpty(serializedPreviews)) { - ContentValues contactValues = new ContentValues(); - contactValues.put(LINK_PREVIEWS, serializedPreviews); - - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - int rows = database.update(TABLE_NAME, contactValues, ID + " = ?", new String[]{ String.valueOf(messageId) }); - - if (rows <= 0) { - Log.w(TAG, "Failed to update message with link preview data."); - } - } - - db.setTransactionSuccessful(); - return messageId; - } finally { - db.endTransaction(); - - if (insertListener != null) { - insertListener.onComplete(); - } - - long contentValuesThreadId = contentValues.getAsLong(THREAD_ID); - - if (updateThread) { - SignalDatabase.threads().setLastScrolled(contentValuesThreadId, 0); - SignalDatabase.threads().update(threadId, unarchive); - } - } - } - - public boolean deleteMessage(long messageId) { - long threadId = getThreadIdForMessage(messageId); - return deleteMessage(messageId, threadId); - } - - public boolean deleteMessage(long messageId, boolean notify) { - long threadId = getThreadIdForMessage(messageId); - return deleteMessage(messageId, threadId, notify); - } - - public boolean deleteMessage(long messageId, long threadId) { - return deleteMessage(messageId, threadId, true); - } - - private boolean deleteMessage(long messageId, long threadId, boolean notify) { - Log.d(TAG, "deleteMessage(" + messageId + ")"); - - AttachmentTable attachmentDatabase = SignalDatabase.attachments(); - attachmentDatabase.deleteAttachmentsForMessage(messageId); - - GroupReceiptTable groupReceiptDatabase = SignalDatabase.groupReceipts(); - groupReceiptDatabase.deleteRowsForMessage(messageId); - - MentionTable mentionDatabase = SignalDatabase.mentions(); - mentionDatabase.deleteMentionsForMessage(messageId); - - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - database.delete(TABLE_NAME, ID_WHERE, new String[] {messageId+""}); - - SignalDatabase.threads().setLastScrolled(threadId, 0); - boolean threadDeleted = SignalDatabase.threads().update(threadId, false); - - if (notify) { - notifyConversationListeners(threadId); - notifyStickerListeners(); - notifyStickerPackListeners(); - OptimizeMessageSearchIndexJob.enqueue(); - } - - return threadDeleted; - } - - public void deleteScheduledMessage(long messageId) { - Log.d(TAG, "deleteScheduledMessage(" + messageId + ")"); - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - long threadId = getThreadIdForMessage(messageId); - db.beginTransaction(); - try { - ContentValues contentValues = new ContentValues(); - contentValues.put(SCHEDULED_DATE, -1); - contentValues.put(DATE_SENT, System.currentTimeMillis()); - contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); - - int rowsUpdated = db.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1)); - if (rowsUpdated > 0) { - deleteMessage(messageId, threadId); - db.setTransactionSuccessful(); - } else { - Log.w(TAG, "tried to delete scheduled message but it may have already been sent"); - } - } finally { - db.endTransaction(); - } - ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary(); - ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId); - } - - public void deleteScheduledMessages(@NonNull RecipientId recipientId) { - Log.d(TAG, "deleteScheduledMessages(" + recipientId + ")"); - Long threadId = SignalDatabase.threads().getThreadIdFor(recipientId); - if (threadId != null) { - SQLiteDatabaseExtensionsKt.withinTransaction(getWritableDatabase(), d -> { - List scheduledMessages = getScheduledMessagesInThread(threadId); - for (MessageRecord record : scheduledMessages) { - deleteScheduledMessage(record.getId()); - } - return Unit.INSTANCE; - }); - } else { - Log.i(TAG, "No thread exists for " + recipientId); - } - } - - public void deleteThread(long threadId) { - Log.d(TAG, "deleteThread(" + threadId + ")"); - Set singleThreadSet = new HashSet<>(); - singleThreadSet.add(threadId); - deleteThreads(singleThreadSet); - } - - private @Nullable String getSerializedSharedContacts(@NonNull Map insertedAttachmentIds, @NonNull List contacts) { - if (contacts.isEmpty()) return null; - - JSONArray sharedContactJson = new JSONArray(); - - for (Contact contact : contacts) { - try { - AttachmentId attachmentId = null; - - if (contact.getAvatarAttachment() != null) { - attachmentId = insertedAttachmentIds.get(contact.getAvatarAttachment()); - } - - Avatar updatedAvatar = new Avatar(attachmentId, - contact.getAvatarAttachment(), - contact.getAvatar() != null && contact.getAvatar().isProfile()); - Contact updatedContact = new Contact(contact, updatedAvatar); - - sharedContactJson.put(new JSONObject(updatedContact.serialize())); - } catch (JSONException | IOException e) { - Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e); - } - } - return sharedContactJson.toString(); - } - - private @Nullable String getSerializedLinkPreviews(@NonNull Map insertedAttachmentIds, @NonNull List previews) { - if (previews.isEmpty()) return null; - - JSONArray linkPreviewJson = new JSONArray(); - - for (LinkPreview preview : previews) { - try { - AttachmentId attachmentId = null; - - if (preview.getThumbnail().isPresent()) { - attachmentId = insertedAttachmentIds.get(preview.getThumbnail().get()); - } - - LinkPreview updatedPreview = new LinkPreview(preview.getUrl(), preview.getTitle(), preview.getDescription(), preview.getDate(), attachmentId); - linkPreviewJson.put(new JSONObject(updatedPreview.serialize())); - } catch (JSONException | IOException e) { - Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e); - } - } - return linkPreviewJson.toString(); - } - - private boolean isDuplicate(IncomingMediaMessage message, long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = DATE_SENT + " = ? AND " + RECIPIENT_ID + " = ? AND " + THREAD_ID + " = ?"; - String[] args = SqlUtil.buildArgs(message.getSentTimeMillis(), message.getFrom().serialize(), threadId); - - try (Cursor cursor = db.query(TABLE_NAME, new String[] { "1" }, query, args, null, null, null, "1")) { - return cursor.moveToFirst(); - } - } - - private boolean isDuplicate(IncomingTextMessage message, long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = DATE_SENT + " = ? AND " + RECIPIENT_ID + " = ? AND " + THREAD_ID + " = ?"; - String[] args = SqlUtil.buildArgs(message.getSentTimestampMillis(), message.getSender().serialize(), threadId); - - try (Cursor cursor = db.query(TABLE_NAME, new String[] { "1" }, query, args, null, null, null, "1")) { - return cursor.moveToFirst(); - } - } - - public boolean isSent(long messageId) { - SQLiteDatabase database = databaseHelper.getSignalReadableDatabase(); - try (Cursor cursor = database.query(TABLE_NAME, new String[] { TYPE }, ID + " = ?", new String[] { String.valueOf(messageId)}, null, null, null)) { - if (cursor != null && cursor.moveToNext()) { - long type = cursor.getLong(cursor.getColumnIndexOrThrow(TYPE)); - return MessageTypes.isSentType(type); - } - } - return false; - } - - public List getProfileChangeDetailsRecords(long threadId, long afterTimestamp) { - String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " >= ? AND " + TYPE + " = ?"; - String[] args = SqlUtil.buildArgs(threadId, afterTimestamp, MessageTypes.PROFILE_CHANGE_TYPE); - - try (MmsReader reader = mmsReaderFor(queryMessages(where, args, true, -1))) { - List results = new ArrayList<>(reader.getCount()); - while (reader.getNext() != null) { - results.add(reader.getCurrent()); - } - - return results; - } - } - - private Cursor queryMessages(@NonNull String where, @Nullable String[] args, boolean reverse, long limit) { - return queryMessages(MMS_PROJECTION, where, args, reverse, limit); - } - - private Cursor queryMessages(@NonNull String[] projection, @NonNull String where, @Nullable String[] args, boolean reverse, long limit) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - - return db.query(TABLE_NAME, - projection, - where, - args, - null, - null, - reverse ? ID + " DESC" : null, - limit > 0 ? String.valueOf(limit) : null); - } - - public Set getAllRateLimitedMessageIds() { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String where = "(" + TYPE + " & " + MessageTypes.TOTAL_MASK + " & " + MessageTypes.MESSAGE_RATE_LIMITED_BIT + ") > 0"; - - Set ids = new HashSet<>(); - - try (Cursor cursor = db.query(TABLE_NAME, new String[] { ID }, where, null, null, null, null)) { - while (cursor.moveToNext()) { - ids.add(CursorUtil.requireLong(cursor, ID)); - } - } - - return ids; - } - - public Cursor getUnexportedInsecureMessages(int limit) { - return rawQuery( - SqlUtil.appendArg(MMS_PROJECTION_WITH_ATTACHMENTS, EXPORT_STATE), - getInsecureMessageClause() + " AND NOT " + EXPORTED, - null, - false, - limit - ); - } - - public long getUnexportedInsecureMessagesEstimatedSize() { - Cursor messageTextSize = SQLiteDatabaseExtensionsKt.select(getReadableDatabase(), "SUM(LENGTH(" + BODY + "))") - .from(TABLE_NAME) - .where(getInsecureMessageClause() + " AND " + EXPORTED + " < ?", MessageExportStatus.EXPORTED) - .run(); - - long bodyTextSize = CursorExtensionsKt.readToSingleLong(messageTextSize); - - String select = "SUM(" + AttachmentTable.TABLE_NAME + "." + AttachmentTable.SIZE + ") AS s"; - String fromJoin = TABLE_NAME + " INNER JOIN " + AttachmentTable.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.MMS_ID; - String where = getInsecureMessageClause() + " AND " + EXPORTED + " < " + MessageExportStatus.EXPORTED.serialize(); - - long fileSize = CursorExtensionsKt.readToSingleLong(getReadableDatabase().rawQuery("SELECT " + select + " FROM " + fromJoin + " WHERE " + where, null)); - - return bodyTextSize + fileSize; - } - - public void deleteExportedMessages() { - beginTransaction(); - try { - List threadsToUpdate = new LinkedList<>(); - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, THREAD_ID_PROJECTION, EXPORTED + " = ?", SqlUtil.buildArgs(MessageExportStatus.EXPORTED), THREAD_ID, null, null, null)) { - while (cursor.moveToNext()) { - threadsToUpdate.add(CursorUtil.requireLong(cursor, THREAD_ID)); - } - } - - getWritableDatabase().delete(TABLE_NAME, EXPORTED + " = ?", SqlUtil.buildArgs(MessageExportStatus.EXPORTED)); - - for (final long threadId : threadsToUpdate) { - SignalDatabase.threads().update(threadId, false); - } - - SignalDatabase.attachments().deleteAbandonedAttachmentFiles(); - - setTransactionSuccessful(); - } finally { - endTransaction(); - OptimizeMessageSearchIndexJob.enqueue(); - } - } - - void deleteThreads(@NonNull Set threadIds) { - Log.d(TAG, "deleteThreads(count: " + threadIds.size() + ")"); - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - String where = ""; - - for (long threadId : threadIds) { - where += THREAD_ID + " = '" + threadId + "' OR "; - } - - where = where.substring(0, where.length() - 4); - - try (Cursor cursor = db.query(TABLE_NAME, new String[] {ID}, where, null, null, null, null)) { - while (cursor != null && cursor.moveToNext()) { - deleteMessage(cursor.getLong(0), false); - } - } - - notifyConversationListeners(threadIds); - notifyStickerListeners(); - notifyStickerPackListeners(); - OptimizeMessageSearchIndexJob.enqueue(); - } - - int deleteMessagesInThreadBeforeDate(long threadId, long date) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - String where = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < " + date; - - return db.delete(TABLE_NAME, where, SqlUtil.buildArgs(threadId)); - } - - int deleteAbandonedMessages() { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - String where = THREAD_ID + " NOT IN (SELECT _id FROM " + ThreadTable.TABLE_NAME + ")"; - - int deletes = db.delete(TABLE_NAME, where, null); - if (deletes > 0) { - Log.i(TAG, "Deleted " + deletes + " abandoned messages"); - } - return deletes; - } - - public void deleteRemotelyDeletedStory(long messageId) { - try (Cursor cursor = getMessageCursor(messageId)) { - if (cursor.moveToFirst() && CursorUtil.requireBoolean(cursor, REMOTE_DELETED)) { - deleteMessage(messageId); - } else { - Log.i(TAG, "Unable to delete remotely deleted story: " + messageId); - } - } - } - - public List getMessagesInThreadAfterInclusive(long threadId, long timestamp, long limit) { - String where = TABLE_NAME + "." + THREAD_ID + " = ? AND " + - TABLE_NAME + "." + DATE_RECEIVED + " >= ? AND " + - TABLE_NAME + "." + SCHEDULED_DATE + " = -1"; - String[] args = SqlUtil.buildArgs(threadId, timestamp); - - try (MmsReader reader = mmsReaderFor(rawQuery(where, args, false, limit))) { - List results = new ArrayList<>(reader.cursor.getCount()); - - while (reader.getNext() != null) { - results.add(reader.getCurrent()); - } - - return results; - } - } - - public void deleteAllThreads() { - Log.d(TAG, "deleteAllThreads()"); - SignalDatabase.attachments().deleteAllAttachments(); - SignalDatabase.groupReceipts().deleteAllRows(); - SignalDatabase.mentions().deleteAllMentions(); - - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - database.delete(TABLE_NAME, null, null); - OptimizeMessageSearchIndexJob.enqueue(); - } - - public @Nullable ViewOnceExpirationInfo getNearestExpiringViewOnceMessage() { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - ViewOnceExpirationInfo info = null; - long nearestExpiration = Long.MAX_VALUE; - - String query = "SELECT " + - TABLE_NAME + "." + ID + ", " + - VIEW_ONCE + ", " + - DATE_RECEIVED + " " + - "FROM " + TABLE_NAME + " INNER JOIN " + AttachmentTable.TABLE_NAME + " " + - "ON " + TABLE_NAME + "." + ID + " = " + AttachmentTable.TABLE_NAME + "." + AttachmentTable.MMS_ID + " " + - "WHERE " + - VIEW_ONCE + " > 0 AND " + - "(" + AttachmentTable.DATA + " NOT NULL OR " + AttachmentTable.TRANSFER_STATE + " != ?)"; - String[] args = new String[] { String.valueOf(AttachmentTable.TRANSFER_PROGRESS_DONE) }; - - try (Cursor cursor = db.rawQuery(query, args)) { - while (cursor != null && cursor.moveToNext()) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); - long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(DATE_RECEIVED)); - long expiresAt = dateReceived + ViewOnceUtil.MAX_LIFESPAN; - - if (info == null || expiresAt < nearestExpiration) { - info = new ViewOnceExpirationInfo(id, dateReceived); - nearestExpiration = expiresAt; - } - } - } - - return info; - } - - /** - * The number of change number messages in the thread. - * Currently only used for tests. - */ - @VisibleForTesting - int getChangeNumberMessageCount(@NonNull RecipientId recipientId) { - try (Cursor cursor = SQLiteDatabaseExtensionsKt - .select(getReadableDatabase(), "COUNT(*)") - .from(TABLE_NAME) - .where(RECIPIENT_ID + " = ? AND " + TYPE + " = ?", recipientId, MessageTypes.CHANGE_NUMBER_TYPE) - .run()) - { - if (cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } - } - - private static @NonNull List parseQuoteMentions(@NonNull Cursor cursor) { - byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_BODY_RANGES)); - BodyRangeList bodyRanges = null; - - if (raw != null) { - try { - bodyRanges = BodyRangeList.parseFrom(raw); - } catch (InvalidProtocolBufferException e) { - Log.w(TAG, "Unable to parse quote body ranges", e); - } - } - - return MentionUtil.bodyRangeListToMentions(bodyRanges); - } - - private static @Nullable BodyRangeList parseQuoteBodyRanges(@NonNull Cursor cursor) { - byte[] data = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_BODY_RANGES)); - - if (data != null) { - try { - final List bodyRanges = Stream.of(BodyRangeList.parseFrom(data).getRangesList()) - .filter(bodyRange -> bodyRange.getAssociatedValueCase() != BodyRangeList.BodyRange.AssociatedValueCase.MENTIONUUID) - .toList(); - - return BodyRangeList.newBuilder().addAllRanges(bodyRanges).build(); - } catch (InvalidProtocolBufferException e) { - // Intentionally left blank - } - } - - return null; - } - - public SQLiteDatabase beginTransaction() { - databaseHelper.getSignalWritableDatabase().beginTransaction(); - return databaseHelper.getSignalWritableDatabase(); - } - - public void setTransactionSuccessful() { - databaseHelper.getSignalWritableDatabase().setTransactionSuccessful(); - } - - public void endTransaction() { - databaseHelper.getSignalWritableDatabase().endTransaction(); - } - - public static MmsReader mmsReaderFor(Cursor cursor) { - return new MmsReader(cursor); - } - - public static OutgoingMmsReader readerFor(OutgoingMessage message, long threadId) { - return new OutgoingMmsReader(message, threadId); - } - - @VisibleForTesting - Optional collapseJoinRequestEventsIfPossible(long threadId, IncomingGroupUpdateMessage message) { - InsertResult result = null; - - - SQLiteDatabase db = getWritableDatabase(); - db.beginTransaction(); - - try { - try (MessageTable.Reader reader = MessageTable.mmsReaderFor(getConversation(threadId, 0, 2))) { - MessageRecord latestMessage = reader.getNext(); - if (latestMessage != null && latestMessage.isGroupV2()) { - Optional changeEditor = message.getChangeEditor(); - if (changeEditor.isPresent() && latestMessage.isGroupV2JoinRequest(changeEditor.get())) { - String encodedBody; - long id; - - MessageRecord secondLatestMessage = reader.getNext(); - if (secondLatestMessage != null && secondLatestMessage.isGroupV2JoinRequest(changeEditor.get())) { - id = secondLatestMessage.getId(); - encodedBody = MessageRecord.createNewContextWithAppendedDeleteJoinRequest(secondLatestMessage, message.getChangeRevision(), changeEditor.get()); - deleteMessage(latestMessage.getId()); - } else { - id = latestMessage.getId(); - encodedBody = MessageRecord.createNewContextWithAppendedDeleteJoinRequest(latestMessage, message.getChangeRevision(), changeEditor.get()); - } - - ContentValues values = new ContentValues(1); - values.put(BODY, encodedBody); - getWritableDatabase().update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(id)); - result = new InsertResult(id, threadId); - } - } - } - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - return Optional.ofNullable(result); - } - - final @NonNull String getOutgoingTypeClause() { - List segments = new ArrayList<>(MessageTypes.OUTGOING_MESSAGE_TYPES.length); - for (long outgoingMessageType : MessageTypes.OUTGOING_MESSAGE_TYPES) { - segments.add("(" + TABLE_NAME + "." + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + " = " + outgoingMessageType + ")"); - } - - return Util.join(segments, " OR "); - } - - public final int getInsecureMessageSentCount(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[]{"COUNT(*)"}; - String query = THREAD_ID + " = ? AND " + getOutgoingInsecureMessageClause() + " AND " + DATE_SENT + " > ?"; - String[] args = new String[]{String.valueOf(threadId), String.valueOf(System.currentTimeMillis() - InsightsConstants.PERIOD_IN_MILLIS)}; - - try (Cursor cursor = db.query(TABLE_NAME, projection, query, args, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } - } - - public final int getInsecureMessageCountForInsights() { - return getMessageCountForRecipientsAndType(getOutgoingInsecureMessageClause()); - } - - public int getInsecureMessageCount() { - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, SqlUtil.COUNT, getInsecureMessageClause(), null, null, null, null)) { - if (cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - public boolean hasSmsExportMessage(long threadId) { - return SQLiteDatabaseExtensionsKt.exists(getReadableDatabase(), TABLE_NAME) - .where(THREAD_ID_WHERE + " AND " + TYPE + " = ?", threadId, MessageTypes.SMS_EXPORT_TYPE) - .run(); - } - - public final int getSecureMessageCountForInsights() { - return getMessageCountForRecipientsAndType(getOutgoingSecureMessageClause()); - } - - public final int getSecureMessageCount(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[] {"COUNT(*)"}; - String query = getSecureMessageClause() + "AND " + THREAD_ID + " = ?"; - String[] args = new String[]{String.valueOf(threadId)}; - - try (Cursor cursor = db.query(TABLE_NAME, projection, query, args, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } - } - - public final int getOutgoingSecureMessageCount(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[] {"COUNT(*)"}; - String query = getOutgoingSecureMessageClause() + - "AND " + THREAD_ID + " = ? " + - "AND (" + TYPE + " & " + MessageTypes.GROUP_LEAVE_BIT + " = 0 OR " + TYPE + " & " + MessageTypes.GROUP_V2_BIT + " = " + MessageTypes.GROUP_V2_BIT + ")"; - String[] args = new String[]{String.valueOf(threadId)}; - - try (Cursor cursor = db.query(TABLE_NAME, projection, query, args, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } - } - - private int getMessageCountForRecipientsAndType(String typeClause) { - - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String[] projection = new String[] {"COUNT(*)"}; - String query = typeClause + " AND " + DATE_SENT + " > ?"; - String[] args = new String[]{String.valueOf(System.currentTimeMillis() - InsightsConstants.PERIOD_IN_MILLIS)}; - - try (Cursor cursor = db.query(TABLE_NAME, projection, query, args, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } - } - - private String getOutgoingInsecureMessageClause() { - return "(" + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + ") = " + MessageTypes.BASE_SENT_TYPE + " AND NOT (" + TYPE + " & " + MessageTypes.SECURE_MESSAGE_BIT + ")"; - } - - private String getOutgoingSecureMessageClause() { - return "(" + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + ") = " + MessageTypes.BASE_SENT_TYPE + " AND (" + TYPE + " & " + (MessageTypes.SECURE_MESSAGE_BIT | MessageTypes.PUSH_MESSAGE_BIT) + ")"; - } - - private String getSecureMessageClause() { - String isSent = "(" + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + ") = " + MessageTypes.BASE_SENT_TYPE; - String isReceived = "(" + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + ") = " + MessageTypes.BASE_INBOX_TYPE; - String isSecure = "(" + TYPE + " & " + (MessageTypes.SECURE_MESSAGE_BIT | MessageTypes.PUSH_MESSAGE_BIT) + ")"; - - return String.format(Locale.ENGLISH, "(%s OR %s) AND %s", isSent, isReceived, isSecure); - } - - protected String getInsecureMessageClause() { - return getInsecureMessageClause(-1); - } - - protected String getInsecureMessageClause(long threadId) { - String isSent = "(" + TABLE_NAME + "." + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + ") = " + MessageTypes.BASE_SENT_TYPE; - String isReceived = "(" + TABLE_NAME + "." + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + ") = " + MessageTypes.BASE_INBOX_TYPE; - String isSecure = "(" + TABLE_NAME + "." + TYPE + " & " + (MessageTypes.SECURE_MESSAGE_BIT | MessageTypes.PUSH_MESSAGE_BIT) + ")"; - String isNotSecure = "(" + TABLE_NAME + "." + TYPE + " <= " + (MessageTypes.BASE_TYPE_MASK | MessageTypes.MESSAGE_ATTRIBUTE_MASK) + ")"; - - String whereClause = String.format(Locale.ENGLISH, "(%s OR %s) AND NOT %s AND %s", isSent, isReceived, isSecure, isNotSecure); - - if (threadId != -1) { - whereClause += " AND " + TABLE_NAME + "." + THREAD_ID + " = " + threadId; - } - - return whereClause; - } - - public int getUnexportedInsecureMessagesCount() { - return getUnexportedInsecureMessagesCount(-1); - } - - public int getUnexportedInsecureMessagesCount(long threadId) { - try (Cursor cursor = getWritableDatabase().query(TABLE_NAME, SqlUtil.COUNT, getInsecureMessageClause(threadId) + " AND " + EXPORTED + " < ?", SqlUtil.buildArgs(MessageExportStatus.EXPORTED), null, null, null)) { - if (cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - /** - * Resets the exported state and exported flag so messages can be re-exported. - */ - public void clearExportState() { - ContentValues values = new ContentValues(2); - values.putNull(EXPORT_STATE); - values.put(EXPORTED, MessageExportStatus.UNEXPORTED.serialize()); - - SQLiteDatabaseExtensionsKt.update(getWritableDatabase(), TABLE_NAME) - .values(values) - .where(EXPORT_STATE + " IS NOT NULL OR " + EXPORTED + " != ?", MessageExportStatus.UNEXPORTED) - .run(); - } - - /** - * Reset the exported status (not state) to the default for clearing errors. - */ - public void clearInsecureMessageExportedErrorStatus() { - ContentValues values = new ContentValues(1); - values.put(EXPORTED, MessageExportStatus.UNEXPORTED.getCode()); - - SQLiteDatabaseExtensionsKt.update(getWritableDatabase(), TABLE_NAME) - .values(values) - .where(EXPORTED + " < ?", MessageExportStatus.UNEXPORTED) - .run(); - } - - public void setReactionsSeen(long threadId, long sinceTimestamp) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - ContentValues values = new ContentValues(); - String whereClause = THREAD_ID + " = ? AND " + REACTIONS_UNREAD + " = ?"; - String[] whereArgs = new String[]{String.valueOf(threadId), "1"}; - - if (sinceTimestamp > -1) { - whereClause += " AND " + DATE_RECEIVED + " <= " + sinceTimestamp; - } - - values.put(REACTIONS_UNREAD, 0); - values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); - - db.update(TABLE_NAME, values, whereClause, whereArgs); - } - - public void setAllReactionsSeen() { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - ContentValues values = new ContentValues(); - String query = REACTIONS_UNREAD + " != ?"; - String[] args = new String[] { "0" }; - - values.put(REACTIONS_UNREAD, 0); - values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); - - db.update(TABLE_NAME, values, query, args); - } - - public void setNotifiedTimestamp(long timestamp, @NonNull List ids) { - if (ids.isEmpty()) { - return; - } - - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - SqlUtil.Query where = SqlUtil.buildSingleCollectionQuery(ID, ids); - ContentValues values = new ContentValues(); - - values.put(NOTIFIED_TIMESTAMP, timestamp); - - db.update(TABLE_NAME, values, where.getWhere(), where.getWhereArgs()); - } - - public void addMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) { - try { - addToDocument(messageId, MISMATCHED_IDENTITIES, - new IdentityKeyMismatch(recipientId, identityKey), - IdentityKeyMismatchSet.class); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - public void removeMismatchedIdentity(long messageId, @NonNull RecipientId recipientId, IdentityKey identityKey) { - try { - removeFromDocument(messageId, MISMATCHED_IDENTITIES, - new IdentityKeyMismatch(recipientId, identityKey), - IdentityKeyMismatchSet.class); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - public void setMismatchedIdentities(long messageId, @NonNull Set mismatches) { - try { - setDocument(databaseHelper.getSignalWritableDatabase(), messageId, MISMATCHED_IDENTITIES, new IdentityKeyMismatchSet(mismatches)); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - public @NonNull List getReportSpamMessageServerGuids(long threadId, long timestamp) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " <= ?"; - String[] args = SqlUtil.buildArgs(threadId, timestamp); - - List data = new ArrayList<>(); - try (Cursor cursor = db.query(TABLE_NAME, new String[] { RECIPIENT_ID, SERVER_GUID, DATE_RECEIVED }, query, args, null, null, DATE_RECEIVED + " DESC", "3")) { - while (cursor.moveToNext()) { - RecipientId id = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); - String serverGuid = CursorUtil.requireString(cursor, SERVER_GUID); - long dateReceived = CursorUtil.requireLong(cursor, DATE_RECEIVED); - - if (!Util.isEmpty(serverGuid)) { - data.add(new ReportSpamData(id, serverGuid, dateReceived)); - } - } - } - return data; - } - - public List getIncomingPaymentRequestThreads() { - Cursor cursor = SQLiteDatabaseExtensionsKt.select(getReadableDatabase(), "DISTINCT " + THREAD_ID) - .from(TABLE_NAME) - .where("(" + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + ") = " + MessageTypes.BASE_INBOX_TYPE + " AND (" + TYPE + " & ?) != 0", MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST) - .run(); - - return CursorExtensionsKt.readToList(cursor, c -> CursorUtil.requireLong(c, THREAD_ID)); - } - - public @Nullable MessageId getPaymentMessage(@NonNull UUID paymentUuid) { - Cursor cursor = SQLiteDatabaseExtensionsKt.select(getReadableDatabase(), ID) - .from(TABLE_NAME) - .where(TYPE + " & ? != 0 AND body = ?", MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION, paymentUuid) - .run(); - - long id = CursorExtensionsKt.readToSingleLong(cursor, -1); - if (id != -1) { - return new MessageId(id); - } else { - return null; - } - } - - /** - * @return The user that added you to the group, otherwise null. - */ - public @Nullable RecipientId getGroupAddedBy(long threadId) { - long lastQuitChecked = System.currentTimeMillis(); - Pair pair; - - do { - pair = getGroupAddedBy(threadId, lastQuitChecked); - if (pair.first() != null) { - return pair.first(); - } else { - lastQuitChecked = pair.second(); - } - - } while (pair.second() != -1); - - return null; - } - - private @NonNull Pair getGroupAddedBy(long threadId, long lastQuitChecked) { - long latestQuit = SignalDatabase.messages().getLatestGroupQuitTimestamp(threadId, lastQuitChecked); - RecipientId id = SignalDatabase.messages().getOldestGroupUpdateSender(threadId, latestQuit); - - return new Pair<>(id, latestQuit); - } - - /** - * Whether or not the message has been quoted by another message. - */ - public boolean isQuoted(@NonNull MessageRecord messageRecord) { - RecipientId author = messageRecord.isOutgoing() ? Recipient.self().getId() : messageRecord.getRecipient().getId(); - long timestamp = messageRecord.getDateSent(); - - String where = MessageTable.QUOTE_ID + " = ? AND " + MessageTable.QUOTE_AUTHOR + " = ? AND " + SCHEDULED_DATE + " = ?"; - String[] whereArgs = SqlUtil.buildArgs(timestamp, author, -1); - - try (Cursor cursor = getReadableDatabase().query(MessageTable.TABLE_NAME, new String[]{ "1" }, where, whereArgs, null, null, null, "1")) { - return cursor.moveToFirst(); - } - } - - /** - * Given a collection of MessageRecords, this will return a set of the IDs of the records that have been quoted by another message. - * Does an efficient bulk lookup that makes it faster than {@link #isQuoted(MessageRecord)} for multiple records. - */ - public Set isQuoted(@NonNull Collection records) { - if (records.isEmpty()) { - return Collections.emptySet(); - } - - Map byQuoteDescriptor = new HashMap<>(records.size()); - List args = new ArrayList<>(records.size()); - - for (MessageRecord record : records) { - long timestamp = record.getDateSent(); - RecipientId author = record.isOutgoing() ? Recipient.self().getId() : record.getRecipient().getId(); - - byQuoteDescriptor.put(new QuoteDescriptor(timestamp, author), record); - args.add(SqlUtil.buildArgs(timestamp, author, -1)); - } - - - String[] projection = new String[] { QUOTE_ID, QUOTE_AUTHOR }; - List queries = SqlUtil.buildCustomCollectionQuery(QUOTE_ID + " = ? AND " + QUOTE_AUTHOR + " = ? AND " + SCHEDULED_DATE + " = ?", args); - Set quotedIds = new HashSet<>(); - - for (SqlUtil.Query query : queries) { - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, projection, query.getWhere(), query.getWhereArgs(), null, null, null)) { - while (cursor.moveToNext()) { - long timestamp = CursorUtil.requireLong(cursor, QUOTE_ID); - RecipientId author = RecipientId.from(CursorUtil.requireString(cursor, QUOTE_AUTHOR)); - QuoteDescriptor quoteLocator = new QuoteDescriptor(timestamp, author); - - quotedIds.add(byQuoteDescriptor.get(quoteLocator).getId()); - } - } - } - - return quotedIds; - } - - public MessageId getRootOfQuoteChain(@NonNull MessageId id) { - MmsMessageRecord targetMessage; - try { - targetMessage = (MmsMessageRecord) SignalDatabase.messages().getMessageRecord(id.getId()); - } catch (NoSuchMessageException e) { - throw new IllegalArgumentException("Invalid message ID!"); - } - - if (targetMessage.getQuote() == null) { - return id; - } - - String query; - if (targetMessage.getQuote().getAuthor().equals(Recipient.self().getId())) { - query = DATE_SENT + " = " + targetMessage.getQuote().getId() + " AND (" + TYPE + " & " + MessageTypes.BASE_TYPE_MASK + ") = " + MessageTypes.BASE_SENT_TYPE; - } else { - query = DATE_SENT + " = " + targetMessage.getQuote().getId() + " AND " + RECIPIENT_ID + " = '" + targetMessage.getQuote().getAuthor().serialize() + "'"; - } - - try (Reader reader = new MmsReader(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, query, null, null, null, "1"))) { - MessageRecord record; - if ((record = reader.getNext()) != null) { - return getRootOfQuoteChain(new MessageId(record.getId())); - } - } - - return id; - } - - public List getAllMessagesThatQuote(@NonNull MessageId id) { - MessageRecord targetMessage; - try { - targetMessage = getMessageRecord(id.getId()); - } catch (NoSuchMessageException e) { - throw new IllegalArgumentException("Invalid message ID!"); - } - - RecipientId author = targetMessage.isOutgoing() ? Recipient.self().getId() : targetMessage.getRecipient().getId(); - String query = QUOTE_ID + " = " + targetMessage.getDateSent() + " AND " + QUOTE_AUTHOR + " = " + author.serialize() + " AND " + SCHEDULED_DATE + " = -1"; - String order = DATE_RECEIVED + " DESC"; - - List records = new ArrayList<>(); - - try (Reader reader = new MmsReader(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, query, null, null, null, order))) { - MessageRecord record; - while ((record = reader.getNext()) != null) { - records.add(record); - records.addAll(getAllMessagesThatQuote(new MessageId(record.getId()))); - } - } - - Collections.sort(records, (lhs, rhs) -> { - if (lhs.getDateReceived() > rhs.getDateReceived()) { - return -1; - } else if (lhs.getDateReceived() < rhs.getDateReceived()) { - return 1; - } else { - return 0; - } - }); - - return records; - } - - public int getQuotedMessagePosition(long threadId, long quoteId, @NonNull RecipientId recipientId) { - String[] projection = new String[]{ DATE_SENT, RECIPIENT_ID, REMOTE_DELETED}; - String order = DATE_RECEIVED + " DESC"; - String selection = THREAD_ID + " = " + threadId + " AND " + STORY_TYPE + " = 0" + " AND " + PARENT_STORY_ID + " <= 0 AND " + SCHEDULED_DATE + " = -1"; - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, projection, selection, null, null, null, order)) { - boolean isOwnNumber = Recipient.resolved(recipientId).isSelf(); - - while (cursor != null && cursor.moveToNext()) { - boolean quoteIdMatches = cursor.getLong(0) == quoteId; - boolean recipientIdMatches = recipientId.equals(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID))); - - if (quoteIdMatches && (recipientIdMatches || isOwnNumber)) { - if (CursorUtil.requireBoolean(cursor, REMOTE_DELETED)) { - return -1; - } else { - return cursor.getPosition(); - } - } - } - } - return -1; - } - - public int getMessagePositionInConversation(long threadId, long receivedTimestamp, @NonNull RecipientId recipientId) { - String[] projection = new String[]{ DATE_RECEIVED, RECIPIENT_ID, REMOTE_DELETED}; - String order = DATE_RECEIVED + " DESC"; - String selection = THREAD_ID + " = " + threadId + " AND " + STORY_TYPE + " = 0" + " AND " + PARENT_STORY_ID + " <= 0 AND " + SCHEDULED_DATE + " = -1"; - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, projection, selection, null, null, null, order)) { - boolean isOwnNumber = Recipient.resolved(recipientId).isSelf(); - - while (cursor != null && cursor.moveToNext()) { - boolean timestampMatches = cursor.getLong(0) == receivedTimestamp; - boolean recipientIdMatches = recipientId.equals(RecipientId.from(cursor.getLong(1))); - - if (timestampMatches && (recipientIdMatches || isOwnNumber)) { - if (CursorUtil.requireBoolean(cursor, REMOTE_DELETED)) { - return -1; - } else { - return cursor.getPosition(); - } - } - } - } - return -1; - } - - public int getMessagePositionInConversation(long threadId, long receivedTimestamp) { - return getMessagePositionInConversation(threadId, 0, receivedTimestamp); - } - - /** - * Retrieves the position of the message with the provided timestamp in the query results you'd - * get from calling {@link #getConversation(long)}. - * - * Note: This could give back incorrect results in the situation where multiple messages have the - * same received timestamp. However, because this was designed to determine where to scroll to, - * you'll still wind up in about the right spot. - * - * @param groupStoryId Ignored if passed value is <= 0 - */ - public int getMessagePositionInConversation(long threadId, long groupStoryId, long receivedTimestamp) { - final String order; - final String selection; - - if (groupStoryId > 0) { - order = MessageTable.DATE_RECEIVED + " ASC"; - selection = MessageTable.THREAD_ID + " = " + threadId + " AND " + - MessageTable.DATE_RECEIVED + " < " + receivedTimestamp + " AND " + - MessageTable.STORY_TYPE + " = 0 AND " + MessageTable.PARENT_STORY_ID + " = " + groupStoryId + " AND " + - MessageTable.SCHEDULED_DATE + " = -1"; - } else { - order = MessageTable.DATE_RECEIVED + " DESC"; - selection = MessageTable.THREAD_ID + " = " + threadId + " AND " + - MessageTable.DATE_RECEIVED + " > " + receivedTimestamp + " AND " + - MessageTable.STORY_TYPE + " = 0 AND " + MessageTable.PARENT_STORY_ID + " <= 0 AND " + - MessageTable.SCHEDULED_DATE + " = -1"; - } - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, new String[] { "COUNT(*)" }, selection, null, null, null, order)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - return -1; - } - - public long getTimestampForFirstMessageAfterDate(long date) { - String[] projection = new String[] { MessageTable.DATE_RECEIVED }; - String order = MessageTable.DATE_RECEIVED + " ASC"; - String selection = MessageTable.DATE_RECEIVED + " > " + date + " AND " + MessageTable.SCHEDULED_DATE + " = -1"; - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, projection, selection, null, null, null, order, "1")) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getLong(0); - } - } - - return 0; - } - - public int getMessageCountBeforeDate(long date) { - String selection = MessageTable.DATE_RECEIVED + " < " + date + " AND " + MessageTable.SCHEDULED_DATE + " = -1"; - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, COUNT, selection, null, null, null, null)) { - if (cursor != null && cursor.moveToFirst()) { - return cursor.getInt(0); - } - } - - return 0; - } - - public @NonNull List getMessagesAfterVoiceNoteInclusive(long messageId, long limit) throws NoSuchMessageException { - MessageRecord origin = getMessageRecord(messageId); - List mms = getMessagesInThreadAfterInclusive(origin.getThreadId(), origin.getDateReceived(), limit); - - Collections.sort(mms, Comparator.comparingLong(DisplayRecord::getDateReceived)); - - return Stream.of(mms).limit(limit).toList(); - } - - public int getMessagePositionOnOrAfterTimestamp(long threadId, long timestamp) { - String[] projection = new String[] { "COUNT(*)" }; - String selection = MessageTable.THREAD_ID + " = " + threadId + " AND " + - MessageTable.DATE_RECEIVED + " >= " + timestamp + " AND " + - MessageTable.STORY_TYPE + " = 0 AND " + MessageTable.PARENT_STORY_ID + " <= 0 AND " + - MessageTable.SCHEDULED_DATE + " = -1"; - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, projection, selection, null, null, null, null)) { - if (cursor != null && cursor.moveToNext()) { - return cursor.getInt(0); - } - } - return 0; - } - - public long getConversationSnippetType(long threadId) throws NoSuchMessageException { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - try (Cursor cursor = db.rawQuery(SNIPPET_QUERY, SqlUtil.buildArgs(threadId))) { - if (cursor.moveToFirst()) { - return CursorUtil.requireLong(cursor, MessageTable.TYPE); - } else { - throw new NoSuchMessageException("no message"); - } - } - } - - public @NonNull MessageRecord getConversationSnippet(long threadId) throws NoSuchMessageException { - try (Cursor cursor = getConversationSnippetCursor(threadId)) { - if (cursor.moveToFirst()) { - long id = CursorUtil.requireLong(cursor, MessageTable.ID); - return SignalDatabase.messages().getMessageRecord(id); - } else { - throw new NoSuchMessageException("no message"); - } - } - } - - @VisibleForTesting - @NonNull Cursor getConversationSnippetCursor(long threadId) { - SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - return db.rawQuery(SNIPPET_QUERY, SqlUtil.buildArgs(threadId)); - } - - public int getUnreadCount(long threadId) { - String selection = READ + " = 0 AND " + STORY_TYPE + " = 0 AND " + THREAD_ID + " = " + threadId + " AND " + PARENT_STORY_ID + " <= 0"; - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME + " INDEXED BY " + INDEX_THREAD_DATE, COUNT, selection, null, null, null, null)) { - if (cursor.moveToFirst()) { - return cursor.getInt(0); - } else { - return 0; - } - } - } - - public boolean checkMessageExists(@NonNull MessageRecord messageRecord) { - return SQLiteDatabaseExtensionsKt - .exists(getReadableDatabase(), TABLE_NAME) - .where(ID + " = ?", messageRecord.getId()) - .run(); - } - - public @NonNull List getReportSpamMessageServerData(long threadId, long timestamp, int limit) { - return getReportSpamMessageServerGuids(threadId, timestamp) - .stream() - .sorted((l, r) -> -Long.compare(l.getDateReceived(), r.getDateReceived())) - .limit(limit) - .collect(java.util.stream.Collectors.toList()); - } - - private @NonNull MessageExportState getMessageExportState(@NonNull MessageId messageId) throws NoSuchMessageException { - String table = MessageTable.TABLE_NAME; - String[] projection = SqlUtil.buildArgs(MessageTable.EXPORT_STATE); - String[] args = SqlUtil.buildArgs(messageId.getId()); - - try (Cursor cursor = getReadableDatabase().query(table, projection, ID_WHERE, args, null, null, null, null)) { - if (cursor.moveToFirst()) { - byte[] bytes = CursorUtil.requireBlob(cursor, MessageTable.EXPORT_STATE); - if (bytes == null) { - return MessageExportState.getDefaultInstance(); - } else { - try { - return MessageExportState.parseFrom(bytes); - } catch (InvalidProtocolBufferException e) { - return MessageExportState.getDefaultInstance(); - } - } - } else { - throw new NoSuchMessageException("The requested message does not exist."); - } - } - } - - public void updateMessageExportState(@NonNull MessageId messageId, @NonNull Function transform) throws NoSuchMessageException { - SQLiteDatabase database = getWritableDatabase(); - - database.beginTransaction(); - try { - MessageExportState oldState = getMessageExportState(messageId); - MessageExportState newState = transform.apply(oldState); - - setMessageExportState(messageId, newState); - - database.setTransactionSuccessful(); - } finally { - database.endTransaction(); - } - } - - public void markMessageExported(@NonNull MessageId messageId) { - ContentValues contentValues = new ContentValues(1); - - contentValues.put(MessageTable.EXPORTED, MessageExportStatus.EXPORTED.getCode()); - - getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(messageId.getId())); - } - - public void markMessageExportFailed(@NonNull MessageId messageId) { - ContentValues contentValues = new ContentValues(1); - - contentValues.put(MessageTable.EXPORTED, MessageExportStatus.ERROR.getCode()); - - getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(messageId.getId())); - } - - private void setMessageExportState(@NonNull MessageId messageId, @NonNull MessageExportState messageExportState) { - ContentValues contentValues = new ContentValues(1); - - contentValues.put(MessageTable.EXPORT_STATE, messageExportState.toByteArray()); - - getWritableDatabase().update(TABLE_NAME, contentValues, ID_WHERE, SqlUtil.buildArgs(messageId.getId())); - } - - public Collection incrementDeliveryReceiptCounts(@NonNull List syncMessageIds, long timestamp) { - return incrementReceiptCounts(syncMessageIds, timestamp, ReceiptType.DELIVERY); - } - - public boolean incrementDeliveryReceiptCount(SyncMessageId syncMessageId, long timestamp) { - return incrementReceiptCount(syncMessageId, timestamp, ReceiptType.DELIVERY); - } - - /** - * @return A list of ID's that were not updated. - */ - public @NonNull Collection incrementReadReceiptCounts(@NonNull List syncMessageIds, long timestamp) { - return incrementReceiptCounts(syncMessageIds, timestamp, ReceiptType.READ); - } - - public boolean incrementReadReceiptCount(SyncMessageId syncMessageId, long timestamp) { - return incrementReceiptCount(syncMessageId, timestamp, ReceiptType.READ); - } - - /** - * @return A list of ID's that were not updated. - */ - public @NonNull Collection incrementViewedReceiptCounts(@NonNull List syncMessageIds, long timestamp) { - return incrementReceiptCounts(syncMessageIds, timestamp, ReceiptType.VIEWED); - } - - public @NonNull Collection incrementViewedNonStoryReceiptCounts(@NonNull List syncMessageIds, long timestamp) { - return incrementReceiptCounts(syncMessageIds, timestamp, ReceiptType.VIEWED, MessageQualifier.NORMAL); - } - - public boolean incrementViewedReceiptCount(SyncMessageId syncMessageId, long timestamp) { - return incrementReceiptCount(syncMessageId, timestamp, ReceiptType.VIEWED); - } - - public @NonNull Collection incrementViewedStoryReceiptCounts(@NonNull List syncMessageIds, long timestamp) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - Set messageUpdates = new HashSet<>(); - Collection unhandled = new HashSet<>(); - - db.beginTransaction(); - try { - for (SyncMessageId id : syncMessageIds) { - Set updates = incrementReceiptCountInternal(id, timestamp, ReceiptType.VIEWED, MessageQualifier.STORY); - - if (updates.size() > 0) { - messageUpdates.addAll(updates); - } else { - unhandled.add(id); - } - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - - for (MessageUpdate update : messageUpdates) { - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(update.getMessageId()); - ApplicationDependencies.getDatabaseObserver().notifyVerboseConversationListeners(Collections.singleton(update.getThreadId())); - } - - if (messageUpdates.size() > 0) { - notifyConversationListListeners(); - } - } - - return unhandled; - } - - /** - * Wraps a single receipt update in a transaction and triggers the proper updates. - * - * @return Whether or not some thread was updated. - */ - private boolean incrementReceiptCount(SyncMessageId syncMessageId, long timestamp, @NonNull MessageTable.ReceiptType receiptType) { - return incrementReceiptCount(syncMessageId, timestamp, receiptType, MessageTable.MessageQualifier.ALL); - } - - private boolean incrementReceiptCount(SyncMessageId syncMessageId, long timestamp, @NonNull MessageTable.ReceiptType receiptType, @NonNull MessageTable.MessageQualifier messageQualifier) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - ThreadTable threadTable = SignalDatabase.threads(); - Set messageUpdates = new HashSet<>(); - - db.beginTransaction(); - try { - messageUpdates = incrementReceiptCountInternal(syncMessageId, timestamp, receiptType, messageQualifier); - - for (MessageUpdate messageUpdate : messageUpdates) { - threadTable.update(messageUpdate.getThreadId(), false); - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - - for (MessageUpdate threadUpdate : messageUpdates) { - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(threadUpdate.getMessageId()); - } - } - - return messageUpdates.size() > 0; - } - - /** - * Wraps multiple receipt updates in a transaction and triggers the proper updates. - * - * @return All of the messages that didn't result in updates. - */ - private @NonNull Collection incrementReceiptCounts(@NonNull List syncMessageIds, long timestamp, @NonNull MessageTable.ReceiptType receiptType) { - return incrementReceiptCounts(syncMessageIds, timestamp, receiptType, MessageTable.MessageQualifier.ALL); - } - - private @NonNull Collection incrementReceiptCounts(@NonNull List syncMessageIds, long timestamp, @NonNull MessageTable.ReceiptType receiptType, @NonNull MessageTable.MessageQualifier messageQualifier) { - SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); - ThreadTable threadTable = SignalDatabase.threads(); - Set messageUpdates = new HashSet<>(); - Collection unhandled = new HashSet<>(); - - db.beginTransaction(); - try { - for (SyncMessageId id : syncMessageIds) { - Set updates = incrementReceiptCountInternal(id, timestamp, receiptType, messageQualifier); - - if (updates.size() > 0) { - messageUpdates.addAll(updates); - } else { - unhandled.add(id); - } - } - - for (MessageUpdate update : messageUpdates) { - threadTable.updateSilently(update.getThreadId(), false); - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - - for (MessageUpdate update : messageUpdates) { - ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(update.getMessageId()); - ApplicationDependencies.getDatabaseObserver().notifyVerboseConversationListeners(Collections.singleton(update.getThreadId())); - - if (messageQualifier == MessageQualifier.STORY) { - ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(Objects.requireNonNull(threadTable.getRecipientIdForThreadId(update.getThreadId()))); - } - } - - if (messageUpdates.size() > 0) { - notifyConversationListListeners(); - } - } - - return unhandled; - } - - private @NonNull Set incrementReceiptCountInternal(SyncMessageId messageId, long timestamp, MessageTable.ReceiptType receiptType, @NonNull MessageTable.MessageQualifier messageQualifier) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - Set messageUpdates = new HashSet<>(); - - final String qualifierWhere; - switch (messageQualifier) { - case NORMAL: - qualifierWhere = " AND NOT (" + IS_STORY_CLAUSE + ")"; - break; - case STORY: - qualifierWhere = " AND " + IS_STORY_CLAUSE; - break; - case ALL: - qualifierWhere = ""; - break; - default: - throw new IllegalArgumentException("Unsupported qualifier: " + messageQualifier); - } - - try (Cursor cursor = SQLiteDatabaseExtensionsKt.select(database, ID, THREAD_ID, TYPE, RECIPIENT_ID, receiptType.getColumnName(), RECEIPT_TIMESTAMP) - .from(TABLE_NAME) - .where(DATE_SENT + " = ?" + qualifierWhere, messageId.getTimetamp()) - .run()) - { - while (cursor.moveToNext()) { - if (MessageTypes.isOutgoingMessageType(CursorUtil.requireLong(cursor, TYPE))) { - RecipientId theirRecipientId = RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)); - RecipientId ourRecipientId = messageId.getRecipientId(); - String columnName = receiptType.getColumnName(); - - if (ourRecipientId.equals(theirRecipientId) || Recipient.resolved(theirRecipientId).isGroup()) { - long id = CursorUtil.requireLong(cursor, ID); - long threadId = CursorUtil.requireLong(cursor, THREAD_ID); - int status = receiptType.getGroupStatus(); - boolean isFirstIncrement = CursorUtil.requireLong(cursor, columnName) == 0; - long savedTimestamp = CursorUtil.requireLong(cursor, RECEIPT_TIMESTAMP); - long updatedTimestamp = isFirstIncrement ? Math.max(savedTimestamp, timestamp) : savedTimestamp; - - database.execSQL("UPDATE " + TABLE_NAME + " SET " + - columnName + " = " + columnName + " + 1, " + - RECEIPT_TIMESTAMP + " = ? WHERE " + - ID + " = ?", - SqlUtil.buildArgs(updatedTimestamp, id)); - - SignalDatabase.groupReceipts().update(ourRecipientId, id, status, timestamp); - - messageUpdates.add(new MessageUpdate(threadId, new MessageId(id))); - } - } - } - - if (messageUpdates.size() > 0 && receiptType == ReceiptType.DELIVERY) { - earlyDeliveryReceiptCache.increment(messageId.getTimetamp(), messageId.getRecipientId(), timestamp); - } - } - - messageUpdates.addAll(incrementStoryReceiptCount(messageId, timestamp, receiptType)); - - return messageUpdates; - } - - private Set incrementStoryReceiptCount(SyncMessageId messageId, long timestamp, @NonNull ReceiptType receiptType) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - Set messageUpdates = new HashSet<>(); - String columnName = receiptType.getColumnName(); - - for (MessageId storyMessageId : SignalDatabase.storySends().getStoryMessagesFor(messageId)) { - database.execSQL("UPDATE " + TABLE_NAME + " SET " + - columnName + " = " + columnName + " + 1, " + - RECEIPT_TIMESTAMP + " = CASE " + - "WHEN " + columnName + " = 0 THEN MAX(" + RECEIPT_TIMESTAMP + ", ?) " + - "ELSE " + RECEIPT_TIMESTAMP + " " + - "END " + - "WHERE " + ID + " = ?", - SqlUtil.buildArgs(timestamp, storyMessageId.getId())); - - SignalDatabase.groupReceipts().update(messageId.getRecipientId(), storyMessageId.getId(), receiptType.getGroupStatus(), timestamp); - - messageUpdates.add(new MessageUpdate(-1, storyMessageId)); - } - - return messageUpdates; - } - - /** - * @return Unhandled ids - */ - public Collection setTimestampReadFromSyncMessage(@NonNull List readMessages, long proposedExpireStarted, @NonNull Map threadToLatestRead) { - SQLiteDatabase db = getWritableDatabase(); - - List> expiringMessages = new LinkedList<>(); - Set updatedThreads = new HashSet<>(); - Collection unhandled = new LinkedList<>(); - - db.beginTransaction(); - try { - for (ReadMessage readMessage : readMessages) { - RecipientId authorId = Recipient.externalPush(readMessage.getSender()).getId(); - TimestampReadResult result = setTimestampReadFromSyncMessageInternal(new SyncMessageId(authorId, readMessage.getTimestamp()), - proposedExpireStarted, - threadToLatestRead); - - expiringMessages.addAll(result.expiring); - updatedThreads.addAll(result.threads); - - if (result.threads.isEmpty()) { - unhandled.add(new SyncMessageId(authorId, readMessage.getTimestamp())); - } - } - - for (long threadId : updatedThreads) { - SignalDatabase.threads().updateReadState(threadId); - SignalDatabase.threads().setLastSeen(threadId); - } - - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - for (Pair expiringMessage : expiringMessages) { - ApplicationDependencies.getExpiringMessageManager() - .scheduleDeletion(expiringMessage.first(), true, proposedExpireStarted, expiringMessage.second()); - } - - for (long threadId : updatedThreads) { - notifyConversationListeners(threadId); - } - - return unhandled; - } - - /** - * Handles a synchronized read message. - * @param messageId An id representing the author-timestamp pair of the message that was read on a linked device. Note that the author could be self when - * syncing read receipts for reactions. - */ - private final @NonNull TimestampReadResult setTimestampReadFromSyncMessageInternal(SyncMessageId messageId, long proposedExpireStarted, @NonNull Map threadToLatestRead) { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - List> expiring = new LinkedList<>(); - String[] projection = new String[] { ID, THREAD_ID, EXPIRES_IN, EXPIRE_STARTED }; - String query = DATE_SENT + " = ? AND (" + RECIPIENT_ID + " = ? OR (" + RECIPIENT_ID + " = ? AND " + getOutgoingTypeClause() + "))"; - String[] args = SqlUtil.buildArgs(messageId.getTimetamp(), messageId.getRecipientId(), Recipient.self().getId()); - List threads = new LinkedList<>(); - - try (Cursor cursor = database.query(TABLE_NAME, projection, query, args, null, null, null)) { - while (cursor.moveToNext()) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(ID)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID)); - long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN)); - long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED)); - - expireStarted = expireStarted > 0 ? Math.min(proposedExpireStarted, expireStarted) : proposedExpireStarted; - - ContentValues values = new ContentValues(); - values.put(READ, 1); - values.put(REACTIONS_UNREAD, 0); - values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis()); - - if (expiresIn > 0) { - values.put(EXPIRE_STARTED, expireStarted); - expiring.add(new Pair<>(id, expiresIn)); - } - - database.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(id)); - - threads.add(threadId); - - Long latest = threadToLatestRead.get(threadId); - threadToLatestRead.put(threadId, (latest != null) ? Math.max(latest, messageId.getTimetamp()) : messageId.getTimetamp()); - } - } - - return new TimestampReadResult(expiring, threads); - } - - /** - * Finds a message by timestamp+author. - * Does *not* include attachments. - */ - public @Nullable MessageRecord getMessageFor(long timestamp, RecipientId authorId) { - Recipient author = Recipient.resolved(authorId); - - String query = DATE_SENT + " = ?"; - String[] args = SqlUtil.buildArgs(timestamp); - - try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, query, args, null, null, null)) { - MessageTable.Reader reader = MessageTable.mmsReaderFor(cursor); - - MessageRecord messageRecord; - - while ((messageRecord = reader.getNext()) != null) { - if ((author.isSelf() && messageRecord.isOutgoing()) || - (!author.isSelf() && messageRecord.getIndividualRecipient().getId().equals(authorId))) - { - return messageRecord; - } - } - } - - return null; - } - - /** - * A cursor containing all of the messages in a given thread, in the proper order. - * This does *not* have attachments in it. - */ - public Cursor getConversation(long threadId) { - return getConversation(threadId, 0, 0); - } - - /** - * A cursor containing all of the messages in a given thread, in the proper order, respecting offset/limit. - * This does *not* have attachments in it. - */ - public Cursor getConversation(long threadId, long offset, long limit) { - String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?"; - String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1); - String order = DATE_RECEIVED + " DESC"; - String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; - - return getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order, limitStr); - } - - /** - * Returns messages ordered for display in a reverse list (newest first). - */ - public List getScheduledMessagesInThread(long threadId) { - String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?"; - String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1); - String order = SCHEDULED_DATE + " DESC, " + ID + " DESC"; - - try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME + " INDEXED BY " + INDEX_THREAD_STORY_SCHEDULED_DATE, MMS_PROJECTION, selection, args, null, null, order))) { - List results = new ArrayList<>(reader.getCount()); - while (reader.getNext() != null) { - results.add(reader.getCurrent()); - } - - return results; - } - } - - /** - * Returns messages order for sending (oldest first). - */ - public List getScheduledMessagesBefore(long time) { - String selection = STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ? AND " + SCHEDULED_DATE + " <= ?"; - String[] args = SqlUtil.buildArgs(0, 0, -1, time); - String order = SCHEDULED_DATE + " ASC, " + ID + " ASC"; - - try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order))) { - List results = new ArrayList<>(reader.getCount()); - while (reader.getNext() != null) { - results.add(reader.getCurrent()); - } - - return results; - } - } - - public @Nullable MessageRecord getOldestScheduledSendTimestamp() { - String[] columns = new String[] { SCHEDULED_DATE }; - String selection = STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?"; - String[] args = SqlUtil.buildArgs(0, 0, -1); - String order = SCHEDULED_DATE + " ASC, " + ID + " ASC"; - String limit = "1"; - - try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order, limit))) { - if (reader.getNext() != null) { - return reader.getCurrent(); - } - } - - return null; - } - - public Cursor getMessagesForNotificationState(Collection stickyThreads) { - StringBuilder stickyQuery = new StringBuilder(); - for (DefaultMessageNotifier.StickyThread stickyThread : stickyThreads) { - if (stickyQuery.length() > 0) { - stickyQuery.append(" OR "); - } - stickyQuery.append("(") - .append(MessageTable.THREAD_ID + " = ") - .append(stickyThread.getConversationId().getThreadId()) - .append(" AND ") - .append(MessageTable.DATE_RECEIVED) - .append(" >= ") - .append(stickyThread.getEarliestTimestamp()) - .append(getStickyWherePartForParentStoryId(stickyThread.getConversationId().getGroupStoryId())) - .append(")"); - } - - String order = MessageTable.DATE_RECEIVED + " ASC"; - String selection = MessageTable.NOTIFIED + " = 0 AND " + MessageTable.STORY_TYPE + " = 0 AND (" + MessageTable.READ + " = 0 OR " + MessageTable.REACTIONS_UNREAD + " = 1" + (stickyQuery.length() > 0 ? " OR (" + stickyQuery + ")" : "") + ")"; - - return getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, null, null, null, order); - } - - private @NonNull String getStickyWherePartForParentStoryId(@Nullable Long parentStoryId) { - if (parentStoryId == null) { - return " AND " + MessageTable.PARENT_STORY_ID + " <= 0"; - } - - return " AND " + MessageTable.PARENT_STORY_ID + " = " + parentStoryId; - } - - @Override - public void remapRecipient(@NonNull RecipientId fromId, @NonNull RecipientId toId) { - ContentValues values = new ContentValues(); - values.put(RECIPIENT_ID, toId.serialize()); - getWritableDatabase().update(TABLE_NAME, values, RECIPIENT_ID + " = ?", SqlUtil.buildArgs(fromId)); - } - - @Override - public void remapThread(long fromId, long toId) { - ContentValues values = new ContentValues(); - values.put(THREAD_ID, toId); - getWritableDatabase().update(TABLE_NAME, values, THREAD_ID + " = ?", SqlUtil.buildArgs(fromId)); - } - - /** - * Returns the next ID that would be generated if an insert was done on this table. - * You should *not* use this for actually generating an ID to use. That will happen automatically! - * This was added for a very narrow usecase, and you probably don't need to use it. - */ - public long getNextId() { - return SqlUtil.getNextAutoIncrementId(getWritableDatabase(), TABLE_NAME); - } - - void updateReactionsUnread(SQLiteDatabase db, long messageId, boolean hasReactions, boolean isRemoval) { - try { - boolean isOutgoing = getMessageRecord(messageId).isOutgoing(); - ContentValues values = new ContentValues(); - - if (!hasReactions) { - values.put(REACTIONS_UNREAD, 0); - } else if (!isRemoval) { - values.put(REACTIONS_UNREAD, 1); - } - - if (isOutgoing && hasReactions) { - values.put(NOTIFIED, 0); - } - - if (values.size() > 0) { - db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(messageId)); - } - } catch (NoSuchMessageException e) { - Log.w(TAG, "Failed to find message " + messageId); - } - } - - protected , I> void removeFromDocument(long messageId, String column, I object, Class clazz) throws IOException { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - database.beginTransaction(); - - try { - D document = getDocument(database, messageId, column, clazz); - Iterator iterator = document.getItems().iterator(); - - while (iterator.hasNext()) { - I item = iterator.next(); - - if (item.equals(object)) { - iterator.remove(); - break; - } - } - - setDocument(database, messageId, column, document); - database.setTransactionSuccessful(); - } finally { - database.endTransaction(); - } - } - - protected , I> void addToDocument(long messageId, String column, final I object, Class clazz) throws IOException { - List list = new ArrayList() {{ - add(object); - }}; - - addToDocument(messageId, column, list, clazz); - } - - protected , I> void addToDocument(long messageId, String column, List objects, Class clazz) throws IOException { - SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); - database.beginTransaction(); - - try { - T document = getDocument(database, messageId, column, clazz); - document.getItems().addAll(objects); - setDocument(database, messageId, column, document); - - database.setTransactionSuccessful(); - } finally { - database.endTransaction(); - } - } - - protected void setDocument(SQLiteDatabase database, long messageId, String column, Document document) throws IOException { - ContentValues contentValues = new ContentValues(); - - if (document == null || document.size() == 0) { - contentValues.put(column, (String)null); - } else { - contentValues.put(column, JsonUtils.toJson(document)); - } - - database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - } - - private D getDocument(SQLiteDatabase database, long messageId, - String column, Class clazz) - { - - try (Cursor cursor = database.query(TABLE_NAME, new String[] { column }, ID_WHERE, new String[] { String.valueOf(messageId) }, null, null, null)) { - if (cursor != null && cursor.moveToNext()) { - String document = cursor.getString(cursor.getColumnIndexOrThrow(column)); - - try { - if (!TextUtils.isEmpty(document)) { - return JsonUtils.fromJson(document, clazz); - } - } catch (IOException e) { - Log.w(TAG, e); - } - } - - try { - return clazz.newInstance(); - } catch (InstantiationException | IllegalAccessException e) { - throw new AssertionError(e); - } - - } - } - - public @NonNull Map getBodyRangesForMessages(@NonNull List messageIds) { - List queries = SqlUtil.buildCollectionQuery(ID, messageIds); - Map bodyRanges = new HashMap<>(); - - for (SqlUtil.Query query : queries) { - try (Cursor cursor = SQLiteDatabaseExtensionsKt.select(getReadableDatabase(), ID, MESSAGE_RANGES) - .from(TABLE_NAME) - .where(query.getWhere(), query.getWhereArgs()) - .run()) - { - while (cursor.moveToNext()) { - byte[] data = CursorUtil.requireBlob(cursor, MESSAGE_RANGES); - if (data != null) { - try { - bodyRanges.put(CursorUtil.requireLong(cursor, ID), BodyRangeList.parseFrom(data)); - } catch (InvalidProtocolBufferException e) { - Log.w(TAG, "Unable to parse body ranges for search", e); - } - } - } - } - } - - return bodyRanges; - } - - protected enum ReceiptType { - READ(READ_RECEIPT_COUNT, GroupReceiptTable.STATUS_READ), - DELIVERY(DELIVERY_RECEIPT_COUNT, GroupReceiptTable.STATUS_DELIVERED), - VIEWED(VIEWED_RECEIPT_COUNT, GroupReceiptTable.STATUS_VIEWED); - - private final String columnName; - private final int groupStatus; - - ReceiptType(String columnName, int groupStatus) { - this.columnName = columnName; - this.groupStatus = groupStatus; - } - - public String getColumnName() { - return columnName; - } - - public int getGroupStatus() { - return groupStatus; - } - } - - public static class SyncMessageId { - - private final RecipientId recipientId; - private final long timetamp; - - public SyncMessageId(@NonNull RecipientId recipientId, long timetamp) { - this.recipientId = recipientId; - this.timetamp = timetamp; - } - - public RecipientId getRecipientId() { - return recipientId; - } - - public long getTimetamp() { - return timetamp; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - final SyncMessageId that = (SyncMessageId) o; - return timetamp == that.timetamp && Objects.equals(recipientId, that.recipientId); - } - - @Override - public int hashCode() { - return Objects.hash(recipientId, timetamp); - } - } - - public static class ExpirationInfo { - - private final long id; - private final long expiresIn; - private final long expireStarted; - private final boolean mms; - - public ExpirationInfo(long id, long expiresIn, long expireStarted, boolean mms) { - this.id = id; - this.expiresIn = expiresIn; - this.expireStarted = expireStarted; - this.mms = mms; - } - - public long getId() { - return id; - } - - public long getExpiresIn() { - return expiresIn; - } - - public long getExpireStarted() { - return expireStarted; - } - - public boolean isMms() { - return mms; - } - } - - public static class MarkedMessageInfo { - - private final long threadId; - private final SyncMessageId syncMessageId; - private final MessageId messageId; - private final ExpirationInfo expirationInfo; - private final StoryType storyType; - - public MarkedMessageInfo(long threadId, @NonNull SyncMessageId syncMessageId, @NonNull MessageId messageId, @Nullable ExpirationInfo expirationInfo, @NonNull StoryType storyType) { - this.threadId = threadId; - this.syncMessageId = syncMessageId; - this.messageId = messageId; - this.expirationInfo = expirationInfo; - this.storyType = storyType; - } - - public long getThreadId() { - return threadId; - } - - public @NonNull SyncMessageId getSyncMessageId() { - return syncMessageId; - } - - public @NonNull MessageId getMessageId() { - return messageId; - } - - public @Nullable ExpirationInfo getExpirationInfo() { - return expirationInfo; - } - - public @NonNull StoryType getStoryType() { - return storyType; - } - } - - public static class InsertResult { - private final long messageId; - private final long threadId; - - public InsertResult(long messageId, long threadId) { - this.messageId = messageId; - this.threadId = threadId; - } - - public long getMessageId() { - return messageId; - } - - public long getThreadId() { - return threadId; - } - } - - public static class MmsNotificationInfo { - private final RecipientId from; - private final String contentLocation; - private final String transactionId; - private final int subscriptionId; - - MmsNotificationInfo(@NonNull RecipientId from, String contentLocation, String transactionId, int subscriptionId) { - this.from = from; - this.contentLocation = contentLocation; - this.transactionId = transactionId; - this.subscriptionId = subscriptionId; - } - - public String getContentLocation() { - return contentLocation; - } - - public String getTransactionId() { - return transactionId; - } - - public int getSubscriptionId() { - return subscriptionId; - } - - public @NonNull RecipientId getFrom() { - return from; - } - } - - static class MessageUpdate { - private final long threadId; - private final MessageId messageId; - - MessageUpdate(long threadId, @NonNull MessageId messageId) { - this.threadId = threadId; - this.messageId = messageId; - } - - public long getThreadId() { - return threadId; - } - - public @NonNull MessageId getMessageId() { - return messageId; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - final MessageUpdate that = (MessageUpdate) o; - return threadId == that.threadId && messageId.equals(that.messageId); - } - - @Override - public int hashCode() { - return Objects.hash(threadId, messageId); - } - } - - - public interface InsertListener { - void onComplete(); - } - - /** - * Allows the developer to safely iterate over and close a cursor containing - * data for MessageRecord objects. Supports for-each loops as well as try-with-resources - * blocks. - * - * Readers are considered "one-shot" and it's on the caller to decide what needs - * to be done with the data. Once read, a reader cannot be read from again. This - * is by design, since reading data out of a cursor involves object creations and - * lookups, so it is in the best interest of app performance to only read out the - * data once. If you need to parse the list multiple times, it is recommended that - * you copy the iterable out into a normal List, or use extension methods such as - * partition. - * - * This reader does not support removal, since this would be considered a destructive - * database call. - */ - public interface Reader extends Closeable, Iterable { - /** - * @deprecated Use the Iterable interface instead. - */ - @Deprecated - MessageRecord getNext(); - - /** - * @deprecated Use the Iterable interface instead. - */ - @Deprecated - MessageRecord getCurrent(); - - /** - * Pulls the export state out of the query, if it is present. - */ - @NonNull MessageExportState getMessageExportStateForCurrentRecord(); - - /** - * From the {@link Closeable} interface, removing the IOException requirement. - */ - void close(); - } - - public static class ReportSpamData { - private final RecipientId recipientId; - private final String serverGuid; - private final long dateReceived; - - public ReportSpamData(RecipientId recipientId, String serverGuid, long dateReceived) { - this.recipientId = recipientId; - this.serverGuid = serverGuid; - this.dateReceived = dateReceived; - } - - public @NonNull RecipientId getRecipientId() { - return recipientId; - } - - public @NonNull String getServerGuid() { - return serverGuid; - } - - public long getDateReceived() { - return dateReceived; - } - } - - private static class QuoteDescriptor { - private final long timestamp; - private final RecipientId author; - - private QuoteDescriptor(long timestamp, RecipientId author) { - this.author = author; - this.timestamp = timestamp; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - final QuoteDescriptor that = (QuoteDescriptor) o; - return timestamp == that.timestamp && author.equals(that.author); - } - - @Override - public int hashCode() { - return Objects.hash(author, timestamp); - } - } - - static final class TimestampReadResult { - final List> expiring; - final List threads; - - TimestampReadResult(@NonNull List> expiring, @NonNull List threads) { - this.expiring = expiring; - this.threads = threads; - } - } - - /** - * Describes which messages to act on. This is used when incrementing receipts. - * Specifically, this was added to support stories having separate viewed receipt settings. - */ - public enum MessageQualifier { - /** - * A normal database message (i.e. not a story) - */ - NORMAL, - /** - * A story message - */ - STORY, - /** - * Both normal and story message - */ - ALL - } - - public static class MmsStatus { - public static final int DOWNLOAD_INITIALIZED = 1; - public static final int DOWNLOAD_NO_CONNECTIVITY = 2; - public static final int DOWNLOAD_CONNECTING = 3; - public static final int DOWNLOAD_SOFT_FAILURE = 4; - public static final int DOWNLOAD_HARD_FAILURE = 5; - public static final int DOWNLOAD_APN_UNAVAILABLE = 6; - } - - public static class OutgoingMmsReader { - - private final Context context; - private final OutgoingMessage message; - private final long id; - private final long threadId; - - public OutgoingMmsReader(OutgoingMessage message, long threadId) { - this.context = ApplicationDependencies.getApplication(); - this.message = message; - this.id = new SecureRandom().nextLong(); - this.threadId = threadId; - } - - public MessageRecord getCurrent() { - SlideDeck slideDeck = new SlideDeck(context, message.getAttachments()); - - CharSequence quoteText = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getText() : null; - List quoteMentions = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getMentions() : Collections.emptyList(); - BodyRangeList quoteBodyRanges = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getBodyRanges() : null; - - if (quoteText != null && (Util.hasItems(quoteMentions) || quoteBodyRanges != null)) { - MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions); - - SpannableString styledText = new SpannableString(updated.getBody()); - MessageStyler.style(BodyRangeUtil.adjustBodyRanges(quoteBodyRanges, updated.getBodyAdjustments()), styledText); - - quoteText = styledText; - quoteMentions = updated.getMentions(); - } - - return new MediaMmsMessageRecord(id, - message.getRecipient(), - message.getRecipient(), - 1, - System.currentTimeMillis(), - System.currentTimeMillis(), - -1, - 0, - threadId, message.getBody(), - slideDeck, - message.isSecure() ? MessageTypes.getOutgoingEncryptedMessageType() : MessageTypes.getOutgoingSmsMessageType(), - Collections.emptySet(), - Collections.emptySet(), - message.getSubscriptionId(), - message.getExpiresIn(), - System.currentTimeMillis(), - message.isViewOnce(), - 0, - message.getOutgoingQuote() != null ? - new Quote(message.getOutgoingQuote().getId(), - message.getOutgoingQuote().getAuthor(), - quoteText, - message.getOutgoingQuote().isOriginalMissing(), - new SlideDeck(context, message.getOutgoingQuote().getAttachments()), - quoteMentions, - message.getOutgoingQuote().getType()) : - null, - message.getSharedContacts(), - message.getLinkPreviews(), - false, - Collections.emptyList(), - false, - false, - 0, - 0, - -1, - null, - message.getStoryType(), - message.getParentStoryId(), - message.getGiftBadge(), - null, - null, - -1); - } - } - - /** - * MessageRecord reader which implements the Iterable interface. This allows it to - * be used with many Kotlin Extension Functions as well as with for-each loops. - * - * Note that it's the responsibility of the developer using the reader to ensure that: - * - * 1. They only utilize one of the two interfaces (legacy or iterator) - * 1. They close this reader after use, preferably via try-with-resources or a use block. - */ - public static class MmsReader implements MessageTable.Reader { - - private final Cursor cursor; - private final Context context; - - public MmsReader(Cursor cursor) { - this.cursor = cursor; - this.context = ApplicationDependencies.getApplication(); - } - - @Override - public MessageRecord getNext() { - if (cursor == null || !cursor.moveToNext()) - return null; - - return getCurrent(); - } - - @Override - public MessageRecord getCurrent() { - long mmsType = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.MMS_MESSAGE_TYPE)); - - if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND) { - return getNotificationMmsMessageRecord(cursor); - } else { - return getMediaMmsMessageRecord(cursor); - } - } - - public MessageId getCurrentId() { - return new MessageId(CursorUtil.requireLong(cursor, ID)); - } - - @Override - public @NonNull MessageExportState getMessageExportStateForCurrentRecord() { - byte[] messageExportState = CursorUtil.requireBlob(cursor, MessageTable.EXPORT_STATE); - if (messageExportState == null) { - return MessageExportState.getDefaultInstance(); - } - - try { - return MessageExportState.parseFrom(messageExportState); - } catch (InvalidProtocolBufferException e) { - return MessageExportState.getDefaultInstance(); - } - } - - public int getCount() { - if (cursor == null) return 0; - else return cursor.getCount(); - } - - private NotificationMmsMessageRecord getNotificationMmsMessageRecord(Cursor cursor) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.ID)); - long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.DATE_SENT)); - long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.DATE_RECEIVED)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.THREAD_ID)); - long mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.TYPE)); - long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.RECIPIENT_ID)); - int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.RECIPIENT_DEVICE_ID)); - Recipient recipient = Recipient.live(RecipientId.from(recipientId)).get(); - - String contentLocation = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.MMS_CONTENT_LOCATION)); - String transactionId = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.MMS_TRANSACTION_ID)); - long messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.MMS_MESSAGE_SIZE)); - long expiry = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.MMS_EXPIRY)); - int status = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.MMS_STATUS)); - int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.DELIVERY_RECEIPT_COUNT)); - int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.READ_RECEIPT_COUNT)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.SMS_SUBSCRIPTION_ID)); - int viewedReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.VIEWED_RECEIPT_COUNT)); - long receiptTimestamp = CursorUtil.requireLong(cursor, MessageTable.RECEIPT_TIMESTAMP); - StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); - ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID)); - String body = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.BODY)); - - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { - readReceiptCount = 0; - } - - byte[]contentLocationBytes = null; - byte[]transactionIdBytes = null; - - if (!TextUtils.isEmpty(contentLocation)) - contentLocationBytes = org.thoughtcrime.securesms.util.Util.toIsoBytes(contentLocation); - - if (!TextUtils.isEmpty(transactionId)) - transactionIdBytes = org.thoughtcrime.securesms.util.Util.toIsoBytes(transactionId); - - SlideDeck slideDeck = new SlideDeck(context, new MmsNotificationAttachment(status, messageSize)); - - GiftBadge giftBadge = null; - if (body != null && MessageTypes.isGiftBadge(mailbox)) { - try { - giftBadge = GiftBadge.parseFrom(Base64.decode(body)); - } catch (IOException e) { - Log.w(TAG, "Error parsing gift badge", e); - } - } - - return new NotificationMmsMessageRecord(id, recipient, recipient, - addressDeviceId, dateSent, dateReceived, deliveryReceiptCount, threadId, - contentLocationBytes, messageSize, expiry, status, - transactionIdBytes, mailbox, subscriptionId, slideDeck, - readReceiptCount, viewedReceiptCount, receiptTimestamp, storyType, - parentStoryId, giftBadge); - } - - private MediaMmsMessageRecord getMediaMmsMessageRecord(Cursor cursor) { - long id = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.ID)); - long dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.DATE_SENT)); - long dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.DATE_RECEIVED)); - long dateServer = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.DATE_SERVER)); - long box = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.TYPE)); - long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.THREAD_ID)); - long recipientId = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.RECIPIENT_ID)); - int addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.RECIPIENT_DEVICE_ID)); - int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.DELIVERY_RECEIPT_COUNT)); - int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.READ_RECEIPT_COUNT)); - String body = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.BODY)); - String mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.MISMATCHED_IDENTITIES)); - String networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.NETWORK_FAILURES)); - int subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.SMS_SUBSCRIPTION_ID)); - long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.EXPIRES_IN)); - long expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.EXPIRE_STARTED)); - boolean unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.UNIDENTIFIED)) == 1; - boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.VIEW_ONCE)) == 1; - boolean remoteDelete = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.REMOTE_DELETED)) == 1; - boolean mentionsSelf = CursorUtil.requireBoolean(cursor, MENTIONS_SELF); - long notifiedTimestamp = CursorUtil.requireLong(cursor, NOTIFIED_TIMESTAMP); - int viewedReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(VIEWED_RECEIPT_COUNT)); - long receiptTimestamp = CursorUtil.requireLong(cursor, RECEIPT_TIMESTAMP); - byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES); - StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); - ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID)); - long scheduledDate = CursorUtil.requireLong(cursor, SCHEDULED_DATE); - - if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { - readReceiptCount = 0; - - if (MessageTypes.isOutgoingMessageType(box) && !storyType.isStory()) { - viewedReceiptCount = 0; - } - } - - Recipient recipient = Recipient.live(RecipientId.from(recipientId)).get(); - Set mismatches = getMismatchedIdentities(mismatchDocument); - Set networkFailures = getFailures(networkDocument); - List attachments = SignalDatabase.attachments().getAttachments(cursor); - List contacts = getSharedContacts(cursor, attachments); - Set contactAttachments = Stream.of(contacts).map(Contact::getAvatarAttachment).withoutNulls().collect(Collectors.toSet()); - List previews = getLinkPreviews(cursor, attachments); - Set previewAttachments = Stream.of(previews).filter(lp -> lp.getThumbnail().isPresent()).map(lp -> lp.getThumbnail().get()).collect(Collectors.toSet()); - SlideDeck slideDeck = buildSlideDeck(context, Stream.of(attachments).filterNot(contactAttachments::contains).filterNot(previewAttachments::contains).toList()); - Quote quote = getQuote(cursor); - BodyRangeList messageRanges = null; - - try { - if (messageRangesData != null) { - messageRanges = BodyRangeList.parseFrom(messageRangesData); - } - } catch (InvalidProtocolBufferException e) { - Log.w(TAG, "Error parsing message ranges", e); - } - - GiftBadge giftBadge = null; - if (body != null && MessageTypes.isGiftBadge(box)) { - try { - giftBadge = GiftBadge.parseFrom(Base64.decode(body)); - } catch (IOException e) { - Log.w(TAG, "Error parsing gift badge", e); - } - } - - return new MediaMmsMessageRecord(id, recipient, recipient, - addressDeviceId, dateSent, dateReceived, dateServer, deliveryReceiptCount, - threadId, body, slideDeck, box, mismatches, - networkFailures, subscriptionId, expiresIn, expireStarted, - isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(), - remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges, - storyType, parentStoryId, giftBadge, null, null, scheduledDate); - } - - private Set getMismatchedIdentities(String document) { - if (!TextUtils.isEmpty(document)) { - try { - return JsonUtils.fromJson(document, IdentityKeyMismatchSet.class).getItems(); - } catch (IOException e) { - Log.w(TAG, e); - } - } - - return Collections.emptySet(); - } - - private Set getFailures(String document) { - if (!TextUtils.isEmpty(document)) { - try { - return JsonUtils.fromJson(document, NetworkFailureSet.class).getItems(); - } catch (IOException ioe) { - Log.w(TAG, ioe); - } - } - - return Collections.emptySet(); - } - - public static SlideDeck buildSlideDeck(@NonNull Context context, @NonNull List attachments) { - List messageAttachments = Stream.of(attachments) - .filterNot(Attachment::isQuote) - .sorted(new DatabaseAttachment.DisplayOrderComparator()) - .toList(); - return new SlideDeck(context, messageAttachments); - } - - private @Nullable Quote getQuote(@NonNull Cursor cursor) { - long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.QUOTE_ID)); - long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(MessageTable.QUOTE_AUTHOR)); - CharSequence quoteText = cursor.getString(cursor.getColumnIndexOrThrow(MessageTable.QUOTE_BODY)); - int quoteType = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.QUOTE_TYPE)); - boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(MessageTable.QUOTE_MISSING)) == 1; - List quoteMentions = parseQuoteMentions(cursor); - BodyRangeList bodyRanges = parseQuoteBodyRanges(cursor); - List attachments = SignalDatabase.attachments().getAttachments(cursor); - List quoteAttachments = Stream.of(attachments).filter(Attachment::isQuote).toList(); - SlideDeck quoteDeck = new SlideDeck(context, quoteAttachments); - - if (quoteId > 0 && quoteAuthor > 0) { - if (quoteText != null && (Util.hasItems(quoteMentions) || bodyRanges != null)) { - MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions); - - SpannableString styledText = new SpannableString(updated.getBody()); - MessageStyler.style(BodyRangeUtil.adjustBodyRanges(bodyRanges, updated.getBodyAdjustments()), styledText); - - quoteText = styledText; - quoteMentions = updated.getMentions(); - } - - return new Quote(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteDeck, quoteMentions, QuoteModel.Type.fromCode(quoteType)); - } else { - return null; - } - } - - @Override - public void close() { - if (cursor != null) { - cursor.close(); - } - } - - @NonNull - @Override - public Iterator iterator() { - return new ReaderIterator(); - } - - private class ReaderIterator implements Iterator { - @Override - public boolean hasNext() { - return cursor != null && cursor.getCount() != 0 && !cursor.isLast(); - } - - @Override - public MessageRecord next() { - MessageRecord record = getNext(); - if (record == null) { - throw new NoSuchElementException(); - } - - return record; - } - } - } - - private long generatePduCompatTimestamp(long time) { - return time - (time % 1000); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt new file mode 100644 index 0000000000..1bd1942c6d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -0,0 +1,5058 @@ +/** + * Copyright (C) 2011 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 //www.gnu.org/licenses/>. + */ +package org.thoughtcrime.securesms.database + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.text.SpannableString +import android.text.TextUtils +import androidx.annotation.VisibleForTesting +import androidx.core.content.contentValuesOf +import com.google.android.mms.pdu_alt.NotificationInd +import com.google.android.mms.pdu_alt.PduHeaders +import com.google.protobuf.InvalidProtocolBufferException +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import org.signal.core.util.CursorUtil +import org.signal.core.util.SqlUtil +import org.signal.core.util.SqlUtil.appendArg +import org.signal.core.util.SqlUtil.buildArgs +import org.signal.core.util.SqlUtil.buildCustomCollectionQuery +import org.signal.core.util.SqlUtil.buildSingleCollectionQuery +import org.signal.core.util.SqlUtil.buildTrueUpdateQuery +import org.signal.core.util.SqlUtil.getNextAutoIncrementId +import org.signal.core.util.delete +import org.signal.core.util.emptyIfNull +import org.signal.core.util.exists +import org.signal.core.util.forEach +import org.signal.core.util.insertInto +import org.signal.core.util.logging.Log +import org.signal.core.util.readToList +import org.signal.core.util.readToSingleInt +import org.signal.core.util.readToSingleLong +import org.signal.core.util.readToSingleObject +import org.signal.core.util.requireBlob +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireNonNullString +import org.signal.core.util.requireString +import org.signal.core.util.select +import org.signal.core.util.toOptional +import org.signal.core.util.toSingleLine +import org.signal.core.util.update +import org.signal.core.util.withinTransaction +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.util.Pair +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.attachments.AttachmentId +import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.attachments.DatabaseAttachment.DisplayOrderComparator +import org.thoughtcrime.securesms.attachments.MmsNotificationAttachment +import org.thoughtcrime.securesms.contactshare.Contact +import org.thoughtcrime.securesms.conversation.MessageStyler.style +import org.thoughtcrime.securesms.database.EarlyReceiptCache.Receipt +import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.attachments +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.distributionLists +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupReceipts +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups +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.reactions +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.storySends +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads +import org.thoughtcrime.securesms.database.documents.Document +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet +import org.thoughtcrime.securesms.database.documents.NetworkFailure +import org.thoughtcrime.securesms.database.documents.NetworkFailureSet +import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.database.model.MessageExportStatus +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord +import org.thoughtcrime.securesms.database.model.ParentStoryId +import org.thoughtcrime.securesms.database.model.ParentStoryId.DirectReply +import org.thoughtcrime.securesms.database.model.ParentStoryId.GroupReply +import org.thoughtcrime.securesms.database.model.Quote +import org.thoughtcrime.securesms.database.model.StoryResult +import org.thoughtcrime.securesms.database.model.StoryType +import org.thoughtcrime.securesms.database.model.StoryType.Companion.fromCode +import org.thoughtcrime.securesms.database.model.StoryViewState +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge +import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails +import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState +import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails +import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent +import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange +import org.thoughtcrime.securesms.insights.InsightsConstants +import org.thoughtcrime.securesms.jobs.OptimizeMessageSearchIndexJob +import org.thoughtcrime.securesms.jobs.TrimThreadJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.mms.IncomingMediaMessage +import org.thoughtcrime.securesms.mms.MessageGroupContext +import org.thoughtcrime.securesms.mms.MmsException +import org.thoughtcrime.securesms.mms.OutgoingMessage +import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier.StickyThread +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo +import org.thoughtcrime.securesms.revealable.ViewOnceUtil +import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage +import org.thoughtcrime.securesms.sms.IncomingTextMessage +import org.thoughtcrime.securesms.stories.Stories.isFeatureEnabled +import org.thoughtcrime.securesms.util.Base64 +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.util.JsonUtils +import org.thoughtcrime.securesms.util.MediaUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.messages.multidevice.ReadMessage +import org.whispersystems.signalservice.api.push.ServiceId +import java.io.Closeable +import java.io.IOException +import java.util.LinkedList +import java.util.Optional +import java.util.UUID +import java.util.function.Function +import kotlin.math.max +import kotlin.math.min + +open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper), MessageTypes, RecipientIdDatabaseReference, ThreadIdDatabaseReference { + + companion object { + private val TAG = Log.tag(MessageTable::class.java) + const val TABLE_NAME = "message" + const val ID = "_id" + const val DATE_SENT = "date_sent" + const val DATE_RECEIVED = "date_received" + const val TYPE = "type" + const val DATE_SERVER = "date_server" + const val THREAD_ID = "thread_id" + const val READ = "read" + const val BODY = "body" + const val RECIPIENT_ID = "recipient_id" + const val RECIPIENT_DEVICE_ID = "recipient_device_id" + const val DELIVERY_RECEIPT_COUNT = "delivery_receipt_count" + const val READ_RECEIPT_COUNT = "read_receipt_count" + const val VIEWED_RECEIPT_COUNT = "viewed_receipt_count" + const val MISMATCHED_IDENTITIES = "mismatched_identities" + const val SMS_SUBSCRIPTION_ID = "subscription_id" + const val EXPIRES_IN = "expires_in" + const val EXPIRE_STARTED = "expire_started" + const val NOTIFIED = "notified" + const val NOTIFIED_TIMESTAMP = "notified_timestamp" + const val UNIDENTIFIED = "unidentified" + const val REACTIONS_UNREAD = "reactions_unread" + const val REACTIONS_LAST_SEEN = "reactions_last_seen" + const val REMOTE_DELETED = "remote_deleted" + const val SERVER_GUID = "server_guid" + const val RECEIPT_TIMESTAMP = "receipt_timestamp" + const val EXPORT_STATE = "export_state" + const val EXPORTED = "exported" + const val MMS_CONTENT_LOCATION = "ct_l" + const val MMS_EXPIRY = "exp" + const val MMS_MESSAGE_TYPE = "m_type" + const val MMS_MESSAGE_SIZE = "m_size" + const val MMS_STATUS = "st" + const val MMS_TRANSACTION_ID = "tr_id" + const val NETWORK_FAILURES = "network_failures" + const val QUOTE_ID = "quote_id" + const val QUOTE_AUTHOR = "quote_author" + const val QUOTE_BODY = "quote_body" + const val QUOTE_MISSING = "quote_missing" + const val QUOTE_BODY_RANGES = "quote_mentions" + const val QUOTE_TYPE = "quote_type" + const val SHARED_CONTACTS = "shared_contacts" + const val LINK_PREVIEWS = "link_previews" + const val MENTIONS_SELF = "mentions_self" + const val MESSAGE_RANGES = "message_ranges" + const val VIEW_ONCE = "view_once" + const val STORY_TYPE = "story_type" + const val PARENT_STORY_ID = "parent_story_id" + const val SCHEDULED_DATE = "scheduled_date" + + const val CREATE_TABLE = """ + CREATE TABLE $TABLE_NAME ( + $ID INTEGER PRIMARY KEY AUTOINCREMENT, + $DATE_SENT INTEGER NOT NULL, + $DATE_RECEIVED INTEGER NOT NULL, + $DATE_SERVER INTEGER DEFAULT -1, + $THREAD_ID INTEGER NOT NULL REFERENCES ${ThreadTable.TABLE_NAME} (${ThreadTable.ID}) ON DELETE CASCADE, + $RECIPIENT_ID INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE, + $RECIPIENT_DEVICE_ID INTEGER, + $TYPE INTEGER NOT NULL, + $BODY TEXT, + $READ INTEGER DEFAULT 0, + $MMS_CONTENT_LOCATION TEXT, + $MMS_EXPIRY INTEGER, + $MMS_MESSAGE_TYPE INTEGER, + $MMS_MESSAGE_SIZE INTEGER, + $MMS_STATUS INTEGER, + $MMS_TRANSACTION_ID TEXT, + $SMS_SUBSCRIPTION_ID INTEGER DEFAULT -1, + $RECEIPT_TIMESTAMP INTEGER DEFAULT -1, + $DELIVERY_RECEIPT_COUNT INTEGER DEFAULT 0, + $READ_RECEIPT_COUNT INTEGER DEFAULT 0, + $VIEWED_RECEIPT_COUNT INTEGER DEFAULT 0, + $MISMATCHED_IDENTITIES TEXT DEFAULT NULL, + $NETWORK_FAILURES TEXT DEFAULT NULL, + $EXPIRES_IN INTEGER DEFAULT 0, + $EXPIRE_STARTED INTEGER DEFAULT 0, + $NOTIFIED INTEGER DEFAULT 0, + $QUOTE_ID INTEGER DEFAULT 0, + $QUOTE_AUTHOR INTEGER DEFAULT 0, + $QUOTE_BODY TEXT DEFAULT NULL, + $QUOTE_MISSING INTEGER DEFAULT 0, + $QUOTE_BODY_RANGES BLOB DEFAULT NULL, + $QUOTE_TYPE INTEGER DEFAULT 0, + $SHARED_CONTACTS TEXT DEFAULT NULL, + $UNIDENTIFIED INTEGER DEFAULT 0, + $LINK_PREVIEWS TEXT DEFAULT NULL, + $VIEW_ONCE INTEGER DEFAULT 0, + $REACTIONS_UNREAD INTEGER DEFAULT 0, + $REACTIONS_LAST_SEEN INTEGER DEFAULT -1, + $REMOTE_DELETED INTEGER DEFAULT 0, + $MENTIONS_SELF INTEGER DEFAULT 0, + $NOTIFIED_TIMESTAMP INTEGER DEFAULT 0, + $SERVER_GUID TEXT DEFAULT NULL, + $MESSAGE_RANGES BLOB DEFAULT NULL, + $STORY_TYPE INTEGER DEFAULT 0, + $PARENT_STORY_ID INTEGER DEFAULT 0, + $EXPORT_STATE BLOB DEFAULT NULL, + $EXPORTED INTEGER DEFAULT 0, + $SCHEDULED_DATE INTEGER DEFAULT -1 + ) + """ + + private const val INDEX_THREAD_DATE = "mms_thread_date_index" + private const val INDEX_THREAD_STORY_SCHEDULED_DATE = "mms_thread_story_parent_story_scheduled_date_index" + + @JvmField + val CREATE_INDEXS = arrayOf( + "CREATE INDEX IF NOT EXISTS mms_read_and_notified_and_thread_id_index ON $TABLE_NAME ($READ, $NOTIFIED, $THREAD_ID)", + "CREATE INDEX IF NOT EXISTS mms_type_index ON $TABLE_NAME ($TYPE)", + "CREATE INDEX IF NOT EXISTS mms_date_sent_index ON $TABLE_NAME ($DATE_SENT, $RECIPIENT_ID, $THREAD_ID)", + "CREATE INDEX IF NOT EXISTS mms_date_server_index ON $TABLE_NAME ($DATE_SERVER)", + "CREATE INDEX IF NOT EXISTS $INDEX_THREAD_DATE ON $TABLE_NAME ($THREAD_ID, $DATE_RECEIVED);", + "CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON $TABLE_NAME ($REACTIONS_UNREAD);", + "CREATE INDEX IF NOT EXISTS mms_story_type_index ON $TABLE_NAME ($STORY_TYPE);", + "CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON $TABLE_NAME ($PARENT_STORY_ID);", + "CREATE INDEX IF NOT EXISTS $INDEX_THREAD_STORY_SCHEDULED_DATE ON $TABLE_NAME ($THREAD_ID, $DATE_RECEIVED, $STORY_TYPE, $PARENT_STORY_ID, $SCHEDULED_DATE);", + "CREATE INDEX IF NOT EXISTS message_quote_id_quote_author_scheduled_date_index ON $TABLE_NAME ($QUOTE_ID, $QUOTE_AUTHOR, $SCHEDULED_DATE);", + "CREATE INDEX IF NOT EXISTS mms_exported_index ON $TABLE_NAME ($EXPORTED);", + "CREATE INDEX IF NOT EXISTS mms_id_type_payment_transactions_index ON $TABLE_NAME ($ID,$TYPE) WHERE $TYPE & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} != 0;" + ) + + private val MMS_PROJECTION_BASE = arrayOf( + "$TABLE_NAME.$ID AS $ID", + THREAD_ID, + DATE_SENT, + DATE_RECEIVED, + DATE_SERVER, + TYPE, + READ, + MMS_CONTENT_LOCATION, + MMS_EXPIRY, + MMS_MESSAGE_TYPE, + MMS_MESSAGE_SIZE, + MMS_STATUS, + MMS_TRANSACTION_ID, + BODY, + RECIPIENT_ID, + RECIPIENT_DEVICE_ID, + DELIVERY_RECEIPT_COUNT, + READ_RECEIPT_COUNT, + MISMATCHED_IDENTITIES, + NETWORK_FAILURES, + SMS_SUBSCRIPTION_ID, + EXPIRES_IN, + EXPIRE_STARTED, + NOTIFIED, + QUOTE_ID, + QUOTE_AUTHOR, + QUOTE_BODY, + QUOTE_TYPE, + QUOTE_MISSING, + QUOTE_BODY_RANGES, + SHARED_CONTACTS, + LINK_PREVIEWS, + UNIDENTIFIED, + VIEW_ONCE, + REACTIONS_UNREAD, + REACTIONS_LAST_SEEN, + REMOTE_DELETED, + MENTIONS_SELF, + NOTIFIED_TIMESTAMP, + VIEWED_RECEIPT_COUNT, + RECEIPT_TIMESTAMP, + MESSAGE_RANGES, + STORY_TYPE, + PARENT_STORY_ID, + SCHEDULED_DATE + ) + + private val MMS_PROJECTION: Array = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}" + + private val MMS_PROJECTION_WITH_ATTACHMENTS: Array = MMS_PROJECTION_BASE + + """ + json_group_array( + json_object( + '${AttachmentTable.ROW_ID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ROW_ID}, + '${AttachmentTable.UNIQUE_ID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.UNIQUE_ID}, + '${AttachmentTable.MMS_ID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MMS_ID}, + '${AttachmentTable.SIZE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.SIZE}, + '${AttachmentTable.FILE_NAME}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.FILE_NAME}, + '${AttachmentTable.DATA}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA}, + '${AttachmentTable.CONTENT_TYPE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.CONTENT_TYPE}, + '${AttachmentTable.CDN_NUMBER}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.CDN_NUMBER}, + '${AttachmentTable.CONTENT_LOCATION}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.CONTENT_LOCATION}, + '${AttachmentTable.FAST_PREFLIGHT_ID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.FAST_PREFLIGHT_ID}, + '${AttachmentTable.VOICE_NOTE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.VOICE_NOTE}, + '${AttachmentTable.BORDERLESS}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.BORDERLESS}, + '${AttachmentTable.VIDEO_GIF}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.VIDEO_GIF}, + '${AttachmentTable.WIDTH}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.WIDTH}, + '${AttachmentTable.HEIGHT}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.HEIGHT}, + '${AttachmentTable.QUOTE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.QUOTE}, + '${AttachmentTable.CONTENT_DISPOSITION}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.CONTENT_DISPOSITION}, + '${AttachmentTable.NAME}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.NAME}, + '${AttachmentTable.TRANSFER_STATE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFER_STATE}, + '${AttachmentTable.CAPTION}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.CAPTION}, + '${AttachmentTable.STICKER_PACK_ID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_ID}, + '${AttachmentTable.STICKER_PACK_KEY}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_PACK_KEY}, + '${AttachmentTable.STICKER_ID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_ID}, + '${AttachmentTable.STICKER_EMOJI}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.STICKER_EMOJI}, + '${AttachmentTable.VISUAL_HASH}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.VISUAL_HASH}, + '${AttachmentTable.TRANSFORM_PROPERTIES}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.TRANSFORM_PROPERTIES}, + '${AttachmentTable.DISPLAY_ORDER}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DISPLAY_ORDER}, + '${AttachmentTable.UPLOAD_TIMESTAMP}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP} + ) + ) AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS} + """.toSingleLine() + + private const val IS_STORY_CLAUSE = "$STORY_TYPE > 0 AND $REMOTE_DELETED = 0" + private const val RAW_ID_WHERE = "$TABLE_NAME.$ID = ?" + + private val SNIPPET_QUERY = + """ + SELECT + $ID, + $TYPE, + $DATE_RECEIVED + FROM + $TABLE_NAME + WHERE + $THREAD_ID = ? AND + $TYPE & ${MessageTypes.GROUP_V2_LEAVE_BITS} != ${MessageTypes.GROUP_V2_LEAVE_BITS} AND + $STORY_TYPE = 0 AND + $PARENT_STORY_ID <= 0 AND + $SCHEDULED_DATE = -1 AND + $TYPE NOT IN ( + ${MessageTypes.PROFILE_CHANGE_TYPE}, + ${MessageTypes.GV1_MIGRATION_TYPE}, + ${MessageTypes.CHANGE_NUMBER_TYPE}, + ${MessageTypes.BOOST_REQUEST_TYPE}, + ${MessageTypes.SMS_EXPORT_TYPE} + ) + ORDER BY $DATE_RECEIVED DESC LIMIT 1 + """.toSingleLine() + + @JvmStatic + fun mmsReaderFor(cursor: Cursor): MmsReader { + return MmsReader(cursor) + } + + private fun getSharedContacts(cursor: Cursor, attachments: List): List { + val serializedContacts: String? = cursor.requireString(SHARED_CONTACTS) + + if (serializedContacts.isNullOrEmpty()) { + return emptyList() + } + + val attachmentIdMap: Map = attachments.associateBy { it.attachmentId } + + try { + val contacts: MutableList = LinkedList() + val jsonContacts = JSONArray(serializedContacts) + + for (i in 0 until jsonContacts.length()) { + val contact: Contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString()) + + if (contact.avatar != null && contact.avatar!!.attachmentId != null) { + val attachment = attachmentIdMap[contact.avatar!!.attachmentId] + + val updatedAvatar = Contact.Avatar( + contact.avatar!!.attachmentId, + attachment, + contact.avatar!!.isProfile + ) + + contacts += Contact(contact, updatedAvatar) + } else { + contacts += contact + } + } + + return contacts + } catch (e: JSONException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } + + return emptyList() + } + + private fun getLinkPreviews(cursor: Cursor, attachments: List): List { + val serializedPreviews: String? = cursor.requireString(LINK_PREVIEWS) + + if (serializedPreviews.isNullOrEmpty()) { + return emptyList() + } + + val attachmentIdMap: Map = attachments.associateBy { it.attachmentId } + + try { + val previews: MutableList = LinkedList() + val jsonPreviews = JSONArray(serializedPreviews) + + for (i in 0 until jsonPreviews.length()) { + val preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString()) + + if (preview.attachmentId != null) { + val attachment = attachmentIdMap[preview.attachmentId] + + if (attachment != null) { + previews += LinkPreview(preview.url, preview.title, preview.description, preview.date, attachment) + } else { + previews += preview + } + } else { + previews += preview + } + } + + return previews + } catch (e: JSONException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to parse shared contacts.", e) + } + + return emptyList() + } + + private fun parseQuoteMentions(cursor: Cursor): List { + val data: ByteArray? = cursor.requireBlob(QUOTE_BODY_RANGES) + + val bodyRanges: BodyRangeList? = if (data != null) { + try { + BodyRangeList.parseFrom(data) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, "Unable to parse quote body ranges", e) + null + } + } else { + null + } + + return MentionUtil.bodyRangeListToMentions(bodyRanges) + } + + private fun parseQuoteBodyRanges(cursor: Cursor): BodyRangeList? { + val data: ByteArray? = cursor.requireBlob(QUOTE_BODY_RANGES) + + if (data != null) { + try { + val bodyRanges = BodyRangeList + .parseFrom(data) + .rangesList + .filter { bodyRange -> bodyRange.associatedValueCase != BodyRangeList.BodyRange.AssociatedValueCase.MENTIONUUID } + + return BodyRangeList.newBuilder().addAllRanges(bodyRanges).build() + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, "Unable to parse quote body ranges", e) + } + } + return null + } + } + + private val earlyDeliveryReceiptCache = EarlyReceiptCache("MmsDelivery") + + private fun getOldestGroupUpdateSender(threadId: Long, minimumDateReceived: Long): RecipientId? { + val type = MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT or MessageTypes.GROUP_UPDATE_BIT or MessageTypes.BASE_INBOX_TYPE + + return readableDatabase + .select(RECIPIENT_ID) + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $TYPE & ? AND $DATE_RECEIVED >= ?", threadId.toString(), type.toString(), minimumDateReceived.toString()) + .limit(1) + .run() + .readToSingleObject { RecipientId.from(it.requireLong(RECIPIENT_ID)) } + } + + fun getExpirationStartedMessages(): Cursor { + val where = "$EXPIRE_STARTED > 0" + return rawQueryWithAttachments(where, null) + } + + fun getMessageCursor(messageId: Long): Cursor { + return internalGetMessage(messageId) + } + + fun hasReceivedAnyCallsSince(threadId: Long, timestamp: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where( + "$THREAD_ID = ? AND $DATE_RECEIVED > ? AND ($TYPE = ? OR $TYPE = ? OR $TYPE = ? OR $TYPE =?)", + threadId, + timestamp, + MessageTypes.INCOMING_AUDIO_CALL_TYPE, + MessageTypes.INCOMING_VIDEO_CALL_TYPE, + MessageTypes.MISSED_AUDIO_CALL_TYPE, + MessageTypes.MISSED_VIDEO_CALL_TYPE + ) + .run() + } + + fun markAsEndSession(id: Long) { + updateTypeBitmask(id, MessageTypes.KEY_EXCHANGE_MASK, MessageTypes.END_SESSION_BIT) + } + + fun markAsInvalidVersionKeyExchange(id: Long) { + updateTypeBitmask(id, 0, MessageTypes.KEY_EXCHANGE_INVALID_VERSION_BIT) + } + + fun markAsDecryptFailed(id: Long) { + updateTypeBitmask(id, MessageTypes.ENCRYPTION_MASK, MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT) + } + + fun markAsNoSession(id: Long) { + updateTypeBitmask(id, MessageTypes.ENCRYPTION_MASK, MessageTypes.ENCRYPTION_REMOTE_NO_SESSION_BIT) + } + + fun markAsUnsupportedProtocolVersion(id: Long) { + updateTypeBitmask(id, MessageTypes.BASE_TYPE_MASK, MessageTypes.UNSUPPORTED_MESSAGE_TYPE) + } + + fun markAsInvalidMessage(id: Long) { + updateTypeBitmask(id, MessageTypes.BASE_TYPE_MASK, MessageTypes.INVALID_MESSAGE_TYPE) + } + + fun markAsLegacyVersion(id: Long) { + updateTypeBitmask(id, MessageTypes.ENCRYPTION_MASK, MessageTypes.ENCRYPTION_REMOTE_LEGACY_BIT) + } + + fun markAsMissedCall(id: Long, isVideoOffer: Boolean) { + updateTypeBitmask(id, MessageTypes.TOTAL_MASK, if (isVideoOffer) MessageTypes.MISSED_VIDEO_CALL_TYPE else MessageTypes.MISSED_AUDIO_CALL_TYPE) + } + + fun markSmsStatus(id: Long, status: Int) { + Log.i(TAG, "Updating ID: $id to status: $status") + + writableDatabase + .update(TABLE_NAME) + .values(MMS_STATUS to status) + .where("$ID = ?", id) + .run() + + val threadId = getThreadIdForMessage(id) + threads.update(threadId, false) + notifyConversationListeners(threadId) + } + + private fun updateTypeBitmask(id: Long, maskOff: Long, maskOn: Long) { + writableDatabase.withinTransaction { db -> + db.execSQL( + """ + UPDATE $TABLE_NAME + SET $TYPE = ($TYPE & ${MessageTypes.TOTAL_MASK - maskOff} | $maskOn ) + WHERE $ID = ? + """.toSingleLine(), + buildArgs(id) + ) + + val threadId = getThreadIdForMessage(id) + threads.updateSnippetTypeSilently(threadId) + } + + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(id)) + ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners() + } + + private fun updateMessageBodyAndType(messageId: Long, body: String, maskOff: Long, maskOn: Long): InsertResult { + writableDatabase.execSQL( + """ + UPDATE $TABLE_NAME + SET + $BODY = ?, + $TYPE = ($TYPE & ${MessageTypes.TOTAL_MASK - maskOff} | $maskOn) + WHERE $ID = ? + """.toSingleLine(), + arrayOf(body, messageId.toString() + "") + ) + + val threadId = getThreadIdForMessage(messageId) + threads.update(threadId, true) + notifyConversationListeners(threadId) + + return InsertResult(messageId, threadId) + } + + fun updateBundleMessageBody(messageId: Long, body: String): InsertResult { + val type = MessageTypes.BASE_INBOX_TYPE or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT + return updateMessageBodyAndType(messageId, body, MessageTypes.TOTAL_MASK, type) + } + + fun getViewedIncomingMessages(threadId: Long): List { + return readableDatabase + .select(ID, RECIPIENT_ID, DATE_SENT, TYPE, THREAD_ID, STORY_TYPE) + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $VIEWED_RECEIPT_COUNT > 0 AND $TYPE & ${MessageTypes.BASE_INBOX_TYPE} = ${MessageTypes.BASE_INBOX_TYPE}", threadId) + .run() + .readToList { it.toMarkedMessageInfo() } + } + + fun setIncomingMessageViewed(messageId: Long): MarkedMessageInfo? { + val results = setIncomingMessagesViewed(listOf(messageId)) + return if (results.isEmpty()) { + null + } else { + results[0] + } + } + + fun setIncomingMessagesViewed(messageIds: List): List { + if (messageIds.isEmpty()) { + return emptyList() + } + + val results: List = readableDatabase + .select(ID, RECIPIENT_ID, DATE_SENT, TYPE, THREAD_ID, STORY_TYPE) + .from(TABLE_NAME) + .where("$ID IN (${Util.join(messageIds, ",")}) AND $VIEWED_RECEIPT_COUNT = 0") + .run() + .readToList { cursor -> + val type = cursor.requireLong(TYPE) + + if (MessageTypes.isSecureType(type) && MessageTypes.isInboxType(type)) { + cursor.toMarkedMessageInfo() + } else { + null + } + } + .filterNotNull() + + val currentTime = System.currentTimeMillis() + SqlUtil + .buildCollectionQuery(ID, results.map { it.messageId.id }) + .forEach { query -> + writableDatabase + .update(TABLE_NAME) + .values( + VIEWED_RECEIPT_COUNT to 1, + RECEIPT_TIMESTAMP to currentTime + ) + .where(query.where, query.whereArgs) + .run() + } + + val threadsUpdated: Set = results + .map { it.threadId } + .toSet() + + val storyRecipientsUpdated: Set = results + .filter { it.storyType.isStory } + .mapNotNull { threads.getRecipientIdForThreadId(it.threadId) } + .toSet() + + notifyConversationListeners(threadsUpdated) + notifyConversationListListeners() + ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(storyRecipientsUpdated) + + return results + } + + fun setOutgoingGiftsRevealed(messageIds: List): List { + val results: List = readableDatabase + .select(ID, RECIPIENT_ID, DATE_SENT, THREAD_ID, STORY_TYPE) + .from(TABLE_NAME) + .where("""$ID IN (${Util.join(messageIds, ",")}) AND (${getOutgoingTypeClause()}) AND ($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK} = ${MessageTypes.SPECIAL_TYPE_GIFT_BADGE}) AND $VIEWED_RECEIPT_COUNT = 0""") + .run() + .readToList { it.toMarkedMessageInfo() } + + val currentTime = System.currentTimeMillis() + SqlUtil + .buildCollectionQuery(ID, results.map { it.messageId.id }) + .forEach { query -> + writableDatabase + .update(TABLE_NAME) + .values( + VIEWED_RECEIPT_COUNT to 1, + RECEIPT_TIMESTAMP to currentTime + ) + .where(query.where, query.whereArgs) + .run() + } + + val threadsUpdated = results + .map { it.threadId } + .toSet() + + notifyConversationListeners(threadsUpdated) + return results + } + + fun insertCallLog(recipientId: RecipientId, type: Long, timestamp: Long): InsertResult { + val unread = MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type) + val recipient = Recipient.resolved(recipientId) + val threadId = threads.getOrCreateThreadIdFor(recipient) + + val values = contentValuesOf( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to timestamp, + READ to if (unread) 0 else 1, + TYPE to type, + THREAD_ID to threadId + ) + + val messageId = writableDatabase.insert(TABLE_NAME, null, values) + + if (unread) { + threads.incrementUnread(threadId, 1, 0) + } + + threads.update(threadId, true) + + notifyConversationListeners(threadId) + TrimThreadJob.enqueueAsync(threadId) + + return InsertResult(messageId, threadId) + } + + fun updateCallLog(messageId: Long, type: Long) { + val unread = MessageTypes.isMissedAudioCall(type) || MessageTypes.isMissedVideoCall(type) + + writableDatabase + .update(TABLE_NAME) + .values( + TYPE to type, + READ to if (unread) 0 else 1 + ) + .where("$ID = ?", messageId) + .run() + + val threadId = getThreadIdForMessage(messageId) + + if (unread) { + threads.incrementUnread(threadId, 1, 0) + } + + threads.update(threadId, true) + + notifyConversationListeners(threadId) + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + } + + fun insertOrUpdateGroupCall( + groupRecipientId: RecipientId, + sender: RecipientId, + timestamp: Long, + peekGroupCallEraId: String?, + peekJoinedUuids: Collection, + isCallFull: Boolean + ) { + val recipient = Recipient.resolved(groupRecipientId) + val threadId = threads.getOrCreateThreadIdFor(recipient) + val peerEraIdSameAsPrevious = updatePreviousGroupCall(threadId, peekGroupCallEraId, peekJoinedUuids, isCallFull) + + writableDatabase.withinTransaction { db -> + if (!peerEraIdSameAsPrevious && !Util.isEmpty(peekGroupCallEraId)) { + val self = Recipient.self() + val markRead = peekJoinedUuids.contains(self.requireServiceId().uuid()) || self.id == sender + val updateDetails = GroupCallUpdateDetails.newBuilder() + .setEraId(peekGroupCallEraId.emptyIfNull()) + .setStartedCallUuid(Recipient.resolved(sender).requireServiceId().toString()) + .setStartedCallTimestamp(timestamp) + .addAllInCallUuids(peekJoinedUuids.map { it.toString() }.toList()) + .setIsCallFull(isCallFull) + .build() + .toByteArray() + + val values = contentValuesOf( + RECIPIENT_ID to sender.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to timestamp, + DATE_SENT to timestamp, + READ to if (markRead) 1 else 0, + BODY to Base64.encodeBytes(updateDetails), + TYPE to MessageTypes.GROUP_CALL_TYPE, + THREAD_ID to threadId + ) + + db.insert(TABLE_NAME, null, values) + + threads.incrementUnread(threadId, 1, 0) + } + + threads.update(threadId, true) + } + + notifyConversationListeners(threadId) + TrimThreadJob.enqueueAsync(threadId) + } + + fun insertOrUpdateGroupCall( + groupRecipientId: RecipientId, + sender: RecipientId, + timestamp: Long, + messageGroupCallEraId: String? + ) { + val threadId = writableDatabase.withinTransaction { db -> + val recipient = Recipient.resolved(groupRecipientId) + val threadId = threads.getOrCreateThreadIdFor(recipient) + + val cursor = db + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$TYPE = ? AND $THREAD_ID = ?", MessageTypes.GROUP_CALL_TYPE, threadId) + .orderBy("$DATE_RECEIVED DESC") + .limit(1) + .run() + + var sameEraId = false + + MmsReader(cursor).use { reader -> + val record: MessageRecord? = reader.firstOrNull() + + if (record != null) { + val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body) + sameEraId = groupCallUpdateDetails.eraId == messageGroupCallEraId && !Util.isEmpty(messageGroupCallEraId) + + if (!sameEraId) { + db.update(TABLE_NAME) + .values(BODY to GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, emptyList(), false)) + .where("$ID = ?", record.id) + .run() + } + } + } + + if (!sameEraId && !Util.isEmpty(messageGroupCallEraId)) { + val updateDetails = GroupCallUpdateDetails.newBuilder() + .setEraId(Util.emptyIfNull(messageGroupCallEraId)) + .setStartedCallUuid(Recipient.resolved(sender).requireServiceId().toString()) + .setStartedCallTimestamp(timestamp) + .addAllInCallUuids(emptyList()) + .setIsCallFull(false) + .build() + .toByteArray() + + val values = contentValuesOf( + RECIPIENT_ID to sender.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to timestamp, + DATE_SENT to timestamp, + READ to 0, + BODY to Base64.encodeBytes(updateDetails), + TYPE to MessageTypes.GROUP_CALL_TYPE, + THREAD_ID to threadId + ) + + db.insert(TABLE_NAME, null, values) + threads.incrementUnread(threadId, 1, 0) + } + + threads.update(threadId, true) + + threadId + } + + notifyConversationListeners(threadId) + TrimThreadJob.enqueueAsync(threadId) + } + + fun updatePreviousGroupCall(threadId: Long, peekGroupCallEraId: String?, peekJoinedUuids: Collection, isCallFull: Boolean): Boolean { + return writableDatabase.withinTransaction { db -> + val cursor = db + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$TYPE = ? AND $THREAD_ID = ?", MessageTypes.GROUP_CALL_TYPE, threadId) + .orderBy("$DATE_RECEIVED DESC") + .limit(1) + .run() + + MmsReader(cursor).use { reader -> + val record = reader.getNext() ?: return@withinTransaction false + val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body) + val containsSelf = peekJoinedUuids.contains(SignalStore.account().requireAci().uuid()) + val sameEraId = groupCallUpdateDetails.eraId == peekGroupCallEraId && !Util.isEmpty(peekGroupCallEraId) + + val inCallUuids = if (sameEraId) { + peekJoinedUuids.map { it.toString() }.toList() + } else { + emptyList() + } + + val contentValues = contentValuesOf( + BODY to GroupCallUpdateDetailsUtil.createUpdatedBody(groupCallUpdateDetails, inCallUuids, isCallFull) + ) + + if (sameEraId && containsSelf) { + contentValues.put(READ, 1) + } + + val query = buildTrueUpdateQuery(ID_WHERE, buildArgs(record.id), contentValues) + val updated = db.update(TABLE_NAME, contentValues, query.where, query.whereArgs) > 0 + + if (updated) { + notifyConversationListeners(threadId) + } + + sameEraId + } + } + } + + @JvmOverloads + fun insertMessageInbox(message: IncomingTextMessage, type: Long = MessageTypes.BASE_INBOX_TYPE): Optional { + var type = type + var tryToCollapseJoinRequestEvents = false + + if (message.isJoined) { + type = type and MessageTypes.TOTAL_MASK - MessageTypes.BASE_TYPE_MASK or MessageTypes.JOINED_TYPE + } else if (message.isPreKeyBundle) { + type = type or (MessageTypes.KEY_EXCHANGE_BIT or MessageTypes.KEY_EXCHANGE_BUNDLE_BIT) + } else if (message.isSecureMessage) { + type = type or MessageTypes.SECURE_MESSAGE_BIT + } else if (message.isGroup) { + val incomingGroupUpdateMessage = message as IncomingGroupUpdateMessage + type = type or MessageTypes.SECURE_MESSAGE_BIT + if (incomingGroupUpdateMessage.isGroupV2) { + type = type or (MessageTypes.GROUP_V2_BIT or MessageTypes.GROUP_UPDATE_BIT) + if (incomingGroupUpdateMessage.isJustAGroupLeave) { + type = type or MessageTypes.GROUP_LEAVE_BIT + } else if (incomingGroupUpdateMessage.isCancelJoinRequest) { + tryToCollapseJoinRequestEvents = true + } + } else if (incomingGroupUpdateMessage.isUpdate) { + type = type or MessageTypes.GROUP_UPDATE_BIT + } else if (incomingGroupUpdateMessage.isQuit) { + type = type or MessageTypes.GROUP_LEAVE_BIT + } + } else if (message.isEndSession) { + type = type or MessageTypes.SECURE_MESSAGE_BIT + type = type or MessageTypes.END_SESSION_BIT + } + + if (message.isPush) { + type = type or MessageTypes.PUSH_MESSAGE_BIT + } + + if (message.isIdentityUpdate) { + type = type or MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT + } + + if (message.isContentPreKeyBundle) { + type = type or MessageTypes.KEY_EXCHANGE_CONTENT_FORMAT + } + + if (message.isIdentityVerified) { + type = type or MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT + } else if (message.isIdentityDefault) { + type = type or MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT + } + + val recipient = Recipient.resolved(message.sender) + + val groupRecipient: Recipient? = if (message.groupId == null) { + null + } else { + val id = recipients.getOrInsertFromPossiblyMigratedGroupId(message.groupId!!) + Recipient.resolved(id) + } + + val silent = message.isIdentityUpdate || + message.isIdentityVerified || + message.isIdentityDefault || + message.isJustAGroupLeave || type and MessageTypes.GROUP_UPDATE_BIT > 0 + + val unread = !silent && ( + message.isSecureMessage || + message.isGroup || + message.isPreKeyBundle || + Util.isDefaultSmsProvider(context) + ) + + val threadId: Long = if (groupRecipient == null) threads.getOrCreateThreadIdFor(recipient) else threads.getOrCreateThreadIdFor(groupRecipient) + + if (tryToCollapseJoinRequestEvents) { + val result = collapseJoinRequestEventsIfPossible(threadId, message as IncomingGroupUpdateMessage) + if (result.isPresent) { + return result + } + } + + val values = ContentValues() + values.put(RECIPIENT_ID, message.sender.serialize()) + values.put(RECIPIENT_DEVICE_ID, message.senderDeviceId) + values.put(DATE_RECEIVED, message.receivedTimestampMillis) + values.put(DATE_SENT, message.sentTimestampMillis) + values.put(DATE_SERVER, message.serverTimestampMillis) + values.put(READ, if (unread) 0 else 1) + values.put(SMS_SUBSCRIPTION_ID, message.subscriptionId) + values.put(EXPIRES_IN, message.expiresIn) + values.put(UNIDENTIFIED, message.isUnidentified) + values.put(BODY, message.messageBody) + values.put(TYPE, type) + values.put(THREAD_ID, threadId) + values.put(SERVER_GUID, message.serverGuid) + + return if (message.isPush && isDuplicate(message, threadId)) { + Log.w(TAG, "Duplicate message (" + message.sentTimestampMillis + "), ignoring...") + Optional.empty() + } else { + val messageId = writableDatabase.insert(TABLE_NAME, null, values) + + if (unread) { + threads.incrementUnread(threadId, 1, 0) + } + + if (!silent) { + threads.update(threadId, true) + TrimThreadJob.enqueueAsync(threadId) + } + + if (message.subscriptionId != -1) { + recipients.setDefaultSubscriptionId(recipient.id, message.subscriptionId) + } + + notifyConversationListeners(threadId) + + Optional.of(InsertResult(messageId, threadId)) + } + } + + fun insertProfileNameChangeMessages(recipient: Recipient, newProfileName: String, previousProfileName: String) { + writableDatabase.withinTransaction { db -> + val groupRecords = groups.getGroupsContainingMember(recipient.id, false) + val profileChangeDetails = ProfileChangeDetails.newBuilder() + .setProfileNameChange( + ProfileChangeDetails.StringChange.newBuilder() + .setNew(newProfileName) + .setPrevious(previousProfileName) + ) + .build() + .toByteArray() + + val threadIdsToUpdate = mutableListOf().apply { + add(threads.getThreadIdFor(recipient.id)) + addAll( + groupRecords + .filter { it.isActive } + .map { threads.getThreadIdFor(it.recipientId) } + ) + } + + threadIdsToUpdate + .filterNotNull() + .forEach { threadId -> + val values = contentValuesOf( + RECIPIENT_ID to recipient.id.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to System.currentTimeMillis(), + READ to 1, + TYPE to MessageTypes.PROFILE_CHANGE_TYPE, + THREAD_ID to threadId, + BODY to Base64.encodeBytes(profileChangeDetails) + ) + db.insert(TABLE_NAME, null, values) + notifyConversationListeners(threadId) + TrimThreadJob.enqueueAsync(threadId) + } + } + } + + fun insertGroupV1MigrationEvents(recipientId: RecipientId, threadId: Long, membershipChange: GroupMigrationMembershipChange) { + insertGroupV1MigrationNotification(recipientId, threadId) + if (!membershipChange.isEmpty) { + insertGroupV1MigrationMembershipChanges(recipientId, threadId, membershipChange) + } + notifyConversationListeners(threadId) + TrimThreadJob.enqueueAsync(threadId) + } + + private fun insertGroupV1MigrationNotification(recipientId: RecipientId, threadId: Long) { + insertGroupV1MigrationMembershipChanges(recipientId, threadId, GroupMigrationMembershipChange.empty()) + } + + private fun insertGroupV1MigrationMembershipChanges(recipientId: RecipientId, threadId: Long, membershipChange: GroupMigrationMembershipChange) { + val values = contentValuesOf( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to System.currentTimeMillis(), + READ to 1, + TYPE to MessageTypes.GV1_MIGRATION_TYPE, + THREAD_ID to threadId + ) + + if (!membershipChange.isEmpty) { + values.put(BODY, membershipChange.serialize()) + } + + databaseHelper.signalWritableDatabase.insert(TABLE_NAME, null, values) + } + + fun insertNumberChangeMessages(recipientId: RecipientId) { + val groupRecords = groups.getGroupsContainingMember(recipientId, false) + + writableDatabase.withinTransaction { db -> + val threadIdsToUpdate = mutableListOf().apply { + add(threads.getThreadIdFor(recipientId)) + addAll( + groupRecords + .filter { it.isActive } + .map { threads.getThreadIdFor(it.recipientId) } + ) + } + + threadIdsToUpdate + .filterNotNull() + .forEach { threadId: Long -> + val values = contentValuesOf( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to System.currentTimeMillis(), + READ to 1, + TYPE to MessageTypes.CHANGE_NUMBER_TYPE, + THREAD_ID to threadId, + BODY to null + ) + + db.insert(TABLE_NAME, null, values) + threads.update(threadId, true) + + TrimThreadJob.enqueueAsync(threadId) + notifyConversationListeners(threadId) + } + } + } + + fun insertBoostRequestMessage(recipientId: RecipientId, threadId: Long) { + writableDatabase + .insertInto(TABLE_NAME) + .values( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to System.currentTimeMillis(), + READ to 1, + TYPE to MessageTypes.BOOST_REQUEST_TYPE, + THREAD_ID to threadId, + BODY to null + ) + .run() + } + + fun insertThreadMergeEvent(recipientId: RecipientId, threadId: Long, event: ThreadMergeEvent) { + writableDatabase + .insertInto(TABLE_NAME) + .values( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to System.currentTimeMillis(), + READ to 1, + TYPE to MessageTypes.THREAD_MERGE_TYPE, + THREAD_ID to threadId, + BODY to Base64.encodeBytes(event.toByteArray()) + ) + .run() + ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId) + } + + fun insertSessionSwitchoverEvent(recipientId: RecipientId, threadId: Long, event: SessionSwitchoverEvent) { + check(FeatureFlags.phoneNumberPrivacy()) { "Should not occur in a non-PNP world!" } + writableDatabase + .insertInto(TABLE_NAME) + .values( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to System.currentTimeMillis(), + READ to 1, + TYPE to MessageTypes.SESSION_SWITCHOVER_TYPE, + THREAD_ID to threadId, + BODY to Base64.encodeBytes(event.toByteArray()) + ) + .run() + ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId) + } + + fun insertSmsExportMessage(recipientId: RecipientId, threadId: Long) { + val updated = writableDatabase.withinTransaction { db -> + if (messages.hasSmsExportMessage(threadId)) { + false + } else { + db.insertInto(TABLE_NAME) + .values( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to 1, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to System.currentTimeMillis(), + READ to 1, + TYPE to MessageTypes.SMS_EXPORT_TYPE, + THREAD_ID to threadId, + BODY to null + ) + .run() + true + } + } + + if (updated) { + ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId) + } + } + + fun endTransaction(database: SQLiteDatabase) { + database.endTransaction() + } + + fun ensureMigration() { + databaseHelper.signalWritableDatabase + } + + fun isStory(messageId: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$IS_STORY_CLAUSE AND $ID = ?", messageId) + .run() + } + + fun getOutgoingStoriesTo(recipientId: RecipientId): Reader { + val recipient = Recipient.resolved(recipientId) + val threadId: Long? = if (recipient.isGroup) { + threads.getThreadIdFor(recipientId) + } else { + null + } + + var where = "$IS_STORY_CLAUSE AND (${getOutgoingTypeClause()})" + val whereArgs: Array + + if (threadId == null) { + where += " AND $RECIPIENT_ID = ?" + whereArgs = buildArgs(recipientId) + } else { + where += " AND $THREAD_ID = ?" + whereArgs = buildArgs(threadId) + } + + return MmsReader(rawQueryWithAttachments(where, whereArgs)) + } + + fun getAllOutgoingStories(reverse: Boolean, limit: Int): Reader { + val where = "$IS_STORY_CLAUSE AND (${getOutgoingTypeClause()})" + return MmsReader(rawQueryWithAttachments(where, null, reverse, limit.toLong())) + } + + fun markAllIncomingStoriesRead(): List { + val where = "$IS_STORY_CLAUSE AND NOT (${getOutgoingTypeClause()}) AND $READ = 0" + val markedMessageInfos = setMessagesRead(where, null) + notifyConversationListListeners() + return markedMessageInfos + } + + fun markOnboardingStoryRead() { + val recipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId ?: return + val where = "$IS_STORY_CLAUSE AND NOT (${getOutgoingTypeClause()}) AND $READ = 0 AND $RECIPIENT_ID = ?" + val markedMessageInfos = setMessagesRead(where, buildArgs(recipientId)) + + if (markedMessageInfos.isNotEmpty()) { + notifyConversationListListeners() + } + } + + fun getAllStoriesFor(recipientId: RecipientId, limit: Int): Reader { + val threadId = threads.getThreadIdIfExistsFor(recipientId) + val where = "$IS_STORY_CLAUSE AND $THREAD_ID = ?" + val whereArgs = buildArgs(threadId) + val cursor = rawQueryWithAttachments(where, whereArgs, false, limit.toLong()) + return MmsReader(cursor) + } + + fun getUnreadStories(recipientId: RecipientId, limit: Int): Reader { + val threadId = threads.getThreadIdIfExistsFor(recipientId) + val query = "$IS_STORY_CLAUSE AND NOT (${getOutgoingTypeClause()}) AND $THREAD_ID = ? AND $VIEWED_RECEIPT_COUNT = ?" + val args = buildArgs(threadId, 0) + return MmsReader(rawQueryWithAttachments(query, args, false, limit.toLong())) + } + + fun getParentStoryIdForGroupReply(messageId: Long): GroupReply? { + return readableDatabase + .select(PARENT_STORY_ID) + .from(TABLE_NAME) + .where("$ID = ?", messageId) + .run() + .readToSingleObject { cursor -> + val parentStoryId: ParentStoryId? = ParentStoryId.deserialize(cursor.requireLong(PARENT_STORY_ID)) + if (parentStoryId != null && parentStoryId.isGroupReply()) { + parentStoryId as GroupReply + } else { + null + } + } + } + + fun getStoryViewState(recipientId: RecipientId): StoryViewState { + if (!isFeatureEnabled()) { + return StoryViewState.NONE + } + val threadId = threads.getThreadIdIfExistsFor(recipientId) + return getStoryViewState(threadId) + } + + /** + * Synchronizes whether we've viewed a recipient's story based on incoming sync messages. + */ + fun updateViewedStories(syncMessageIds: Set) { + val timestamps: String = syncMessageIds + .map { it.timetamp } + .joinToString(",") + + writableDatabase.withinTransaction { db -> + db.select(RECIPIENT_ID) + .from(TABLE_NAME) + .where("$IS_STORY_CLAUSE AND $DATE_SENT IN ($timestamps) AND NOT (${getOutgoingTypeClause()}) AND $VIEWED_RECEIPT_COUNT > 0") + .run() + .readToList { cursor -> RecipientId.from(cursor.requireLong(RECIPIENT_ID)) } + .forEach { id -> recipients.updateLastStoryViewTimestamp(id) } + } + } + + @VisibleForTesting + fun getStoryViewState(threadId: Long): StoryViewState { + val hasStories = readableDatabase + .exists(TABLE_NAME) + .where("$IS_STORY_CLAUSE AND $THREAD_ID = ?", threadId) + .run() + + if (!hasStories) { + return StoryViewState.NONE + } + + val hasUnviewedStories = readableDatabase + .exists(TABLE_NAME) + .where("$IS_STORY_CLAUSE AND $THREAD_ID = ? AND $VIEWED_RECEIPT_COUNT = ? AND NOT (${getOutgoingTypeClause()})", threadId, 0) + .run() + + return if (hasUnviewedStories) { + StoryViewState.UNVIEWED + } else { + StoryViewState.VIEWED + } + } + + fun isOutgoingStoryAlreadyInDatabase(recipientId: RecipientId, sentTimestamp: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$RECIPIENT_ID = ? AND $STORY_TYPE > 0 AND $DATE_SENT = ? AND (${getOutgoingTypeClause()})", recipientId, sentTimestamp) + .run() + } + + @Throws(NoSuchMessageException::class) + fun getStoryId(authorId: RecipientId, sentTimestamp: Long): MessageId { + return readableDatabase + .select(ID, RECIPIENT_ID) + .from(TABLE_NAME) + .where("$IS_STORY_CLAUSE AND $DATE_SENT = ?", sentTimestamp) + .run() + .readToSingleObject { cursor -> + val rowRecipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID))) + if (Recipient.self().id == authorId || rowRecipientId == authorId) { + MessageId(CursorUtil.requireLong(cursor, ID)) + } else { + null + } + } ?: throw NoSuchMessageException("No story sent at $sentTimestamp") + } + + fun getUnreadStoryThreadRecipientIds(): List { + val query = """ + SELECT DISTINCT ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} + FROM $TABLE_NAME + JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} + WHERE + $IS_STORY_CLAUSE AND + (${getOutgoingTypeClause()}) = 0 AND + $VIEWED_RECEIPT_COUNT = 0 AND + $TABLE_NAME.$READ = 0 + """.toSingleLine() + + return readableDatabase + .rawQuery(query, null) + .readToList { RecipientId.from(it.getLong(0)) } + } + + fun getOrderedStoryRecipientsAndIds(isOutgoingOnly: Boolean): List { + val query = """ + SELECT + $TABLE_NAME.$DATE_SENT AS sent_timestamp, + $TABLE_NAME.$ID AS mms_id, + ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID}, + (${getOutgoingTypeClause()}) AS is_outgoing, + $VIEWED_RECEIPT_COUNT, + $TABLE_NAME.$DATE_SENT, + $RECEIPT_TIMESTAMP, + (${getOutgoingTypeClause()}) = 0 AND $VIEWED_RECEIPT_COUNT = 0 AS is_unread + FROM $TABLE_NAME + JOIN ${ThreadTable.TABLE_NAME} ON $TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID} + WHERE + $STORY_TYPE > 0 AND + $REMOTE_DELETED = 0 + ${if (isOutgoingOnly) " AND is_outgoing != 0" else ""} + ORDER BY + is_unread DESC, + CASE + WHEN is_outgoing = 0 AND $VIEWED_RECEIPT_COUNT = 0 THEN $TABLE_NAME.$DATE_SENT + WHEN is_outgoing = 0 AND viewed_receipt_count > 0 THEN $RECEIPT_TIMESTAMP + WHEN is_outgoing = 1 THEN $TABLE_NAME.$DATE_SENT + END DESC + """.toSingleLine() + + return readableDatabase + .rawQuery(query, null) + .readToList { cursor -> + StoryResult( + RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)), + CursorUtil.requireLong(cursor, "mms_id"), + CursorUtil.requireLong(cursor, "sent_timestamp"), + CursorUtil.requireBoolean(cursor, "is_outgoing") + ) + } + } + + fun getStoryReplies(parentStoryId: Long): Cursor { + val where = "$PARENT_STORY_ID = ?" + val whereArgs = buildArgs(parentStoryId) + return rawQueryWithAttachments(where, whereArgs, false, 0) + } + + fun getNumberOfStoryReplies(parentStoryId: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$PARENT_STORY_ID = ?", parentStoryId) + .run() + .readToSingleInt() + } + + fun containsStories(threadId: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$THREAD_ID = ? AND $STORY_TYPE > 0", threadId) + .run() + } + + fun hasSelfReplyInStory(parentStoryId: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$PARENT_STORY_ID = ? AND (${getOutgoingTypeClause()})", -parentStoryId) + .run() + } + + fun hasGroupReplyOrReactionInStory(parentStoryId: Long): Boolean { + return hasSelfReplyInStory(-parentStoryId) + } + + fun getOldestStorySendTimestamp(hasSeenReleaseChannelStories: Boolean): Long? { + val releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories) + + return readableDatabase + .select(DATE_SENT) + .from(TABLE_NAME) + .where("$IS_STORY_CLAUSE AND $THREAD_ID != ?", releaseChannelThreadId) + .limit(1) + .orderBy("$DATE_SENT ASC") + .run() + .readToSingleObject { it.getLong(0) } + } + + @VisibleForTesting + fun deleteGroupStoryReplies(parentStoryId: Long) { + writableDatabase + .delete(TABLE_NAME) + .where("$PARENT_STORY_ID = ?", parentStoryId) + .run() + } + + fun deleteStoriesOlderThan(timestamp: Long, hasSeenReleaseChannelStories: Boolean): Int { + return writableDatabase.withinTransaction { db -> + val releaseChannelThreadId = getReleaseChannelThreadId(hasSeenReleaseChannelStories) + val storiesBeforeTimestampWhere = "$IS_STORY_CLAUSE AND $DATE_SENT < ? AND $THREAD_ID != ?" + val sharedArgs = buildArgs(timestamp, releaseChannelThreadId) + + val deleteStoryRepliesQuery = """ + DELETE FROM $TABLE_NAME + WHERE + $PARENT_STORY_ID > 0 AND + $PARENT_STORY_ID IN ( + SELECT $ID + FROM $TABLE_NAME + WHERE $storiesBeforeTimestampWhere + ) + """.toSingleLine() + + val disassociateQuoteQuery = """ + UPDATE $TABLE_NAME + SET + $QUOTE_MISSING = 1, + $QUOTE_BODY = '' + WHERE + $PARENT_STORY_ID < 0 AND + ABS($PARENT_STORY_ID) IN ( + SELECT $ID + FROM $TABLE_NAME + WHERE $storiesBeforeTimestampWhere + ) + """.toSingleLine() + + db.execSQL(deleteStoryRepliesQuery, sharedArgs) + db.execSQL(disassociateQuoteQuery, sharedArgs) + + db.select(RECIPIENT_ID) + .from(TABLE_NAME) + .where(storiesBeforeTimestampWhere, sharedArgs) + .run() + .readToList { RecipientId.from(it.requireLong(RECIPIENT_ID)) } + .forEach { id -> ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(id) } + + val deletedStoryCount = db.select(ID) + .from(TABLE_NAME) + .where(storiesBeforeTimestampWhere, sharedArgs) + .run() + .use { cursor -> + while (cursor.moveToNext()) { + deleteMessage(cursor.requireLong(ID)) + } + + cursor.count + } + + if (deletedStoryCount > 0) { + OptimizeMessageSearchIndexJob.enqueue() + } + + deletedStoryCount + } + } + + private fun disassociateStoryQuotes(storyId: Long) { + writableDatabase + .update(TABLE_NAME) + .values( + QUOTE_MISSING to 1, + QUOTE_BODY to null + ) + .where("$PARENT_STORY_ID = ?", DirectReply(storyId).serialize()) + .run() + } + + fun isGroupQuitMessage(messageId: Long): Boolean { + val type = MessageTypes.getOutgoingEncryptedMessageType() or MessageTypes.GROUP_LEAVE_BIT + + return readableDatabase + .exists(TABLE_NAME) + .where("$ID = ? AND $TYPE & $type = $type AND $TYPE & ${MessageTypes.GROUP_V2_BIT} = 0", messageId) + .run() + } + + fun getLatestGroupQuitTimestamp(threadId: Long, quitTimeBarrier: Long): Long { + val type = MessageTypes.getOutgoingEncryptedMessageType() or MessageTypes.GROUP_LEAVE_BIT + + return readableDatabase + .select(DATE_SENT) + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $TYPE & $type = $type AND $TYPE & ${MessageTypes.GROUP_V2_BIT} = 0 AND $DATE_SENT < ?", threadId, quitTimeBarrier) + .orderBy("$DATE_SENT DESC") + .limit(1) + .run() + .readToSingleLong(-1) + } + + fun getScheduledMessageCountForThread(threadId: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE") + .where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ?", threadId, 0, 0, -1) + .run() + .readToSingleInt() + } + + fun getMessageCountForThread(threadId: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE") + .where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, 0, 0, -1) + .run() + .readToSingleInt() + } + + fun getMessageCountForThread(threadId: Long, beforeTime: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE") + .where("$THREAD_ID = ? AND $DATE_RECEIVED < ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, beforeTime, 0, 0, -1) + .run() + .readToSingleInt() + } + + fun hasMeaningfulMessage(threadId: Long): Boolean { + if (threadId == -1L) { + return false + } + + val query = buildMeaningfulMessagesQuery(threadId) + return readableDatabase + .exists(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + } + + fun getIncomingMeaningfulMessageCountSince(threadId: Long, afterTime: Long): Int { + val meaningfulMessagesQuery = buildMeaningfulMessagesQuery(threadId) + val where = "${meaningfulMessagesQuery.where} AND $DATE_RECEIVED >= ? AND NOT (${getOutgoingTypeClause()})" + val whereArgs = appendArg(meaningfulMessagesQuery.whereArgs, afterTime.toString()) + + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where(where, whereArgs) + .run() + .readToSingleInt() + } + + private fun buildMeaningfulMessagesQuery(threadId: Long): SqlUtil.Query { + val query = """ + $THREAD_ID = ? AND + $STORY_TYPE = ? AND + $PARENT_STORY_ID <= ? AND + ( + NOT $TYPE & ? AND + $TYPE != ? AND + $TYPE != ? AND + $TYPE != ? AND + $TYPE != ? AND + $TYPE & ${MessageTypes.GROUP_V2_LEAVE_BITS} != ${MessageTypes.GROUP_V2_LEAVE_BITS} + ) + """.toSingleLine() + + return SqlUtil.buildQuery(query, threadId, 0, 0, MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING, MessageTypes.PROFILE_CHANGE_TYPE, MessageTypes.CHANGE_NUMBER_TYPE, MessageTypes.SMS_EXPORT_TYPE, MessageTypes.BOOST_REQUEST_TYPE) + } + + fun setNetworkFailures(messageId: Long, failures: Set?) { + try { + setDocument(databaseHelper.signalWritableDatabase, messageId, NETWORK_FAILURES, NetworkFailureSet(failures)) + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + fun getThreadIdForMessage(id: Long): Long { + return readableDatabase + .select(THREAD_ID) + .from(TABLE_NAME) + .where("$ID = ?", id) + .run() + .readToSingleLong(-1) + } + + private fun getThreadIdFor(retrieved: IncomingMediaMessage): Long { + return if (retrieved.groupId != null) { + val groupRecipientId = recipients.getOrInsertFromPossiblyMigratedGroupId(retrieved.groupId) + val groupRecipients = Recipient.resolved(groupRecipientId) + threads.getOrCreateThreadIdFor(groupRecipients) + } else { + val sender = Recipient.resolved(retrieved.from!!) + threads.getOrCreateThreadIdFor(sender) + } + } + + private fun getThreadIdFor(notification: NotificationInd): Long { + val fromString = if (notification.from != null && notification.from.textString != null) { + Util.toIsoString(notification.from.textString) + } else { + "" + } + + val recipient = Recipient.external(context, fromString) + return threads.getOrCreateThreadIdFor(recipient) + } + + private fun rawQueryWithAttachments(where: String, arguments: Array?, reverse: Boolean = false, limit: Long = 0): Cursor { + return rawQueryWithAttachments(MMS_PROJECTION_WITH_ATTACHMENTS, where, arguments, reverse, limit) + } + + private fun rawQueryWithAttachments(projection: Array, where: String, arguments: Array?, reverse: Boolean, limit: Long): Cursor { + val database = databaseHelper.signalReadableDatabase + var rawQueryString = """ + SELECT + ${Util.join(projection, ",")} + FROM + $TABLE_NAME LEFT OUTER JOIN ${AttachmentTable.TABLE_NAME} ON ($TABLE_NAME.$ID = ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MMS_ID}) + WHERE + $where + GROUP BY + $TABLE_NAME.$ID + """.toSingleLine() + + if (reverse) { + rawQueryString += " ORDER BY $TABLE_NAME.$ID DESC" + } + + if (limit > 0) { + rawQueryString += " LIMIT $limit" + } + + return database.rawQuery(rawQueryString, arguments) + } + + private fun internalGetMessage(messageId: Long): Cursor { + return rawQueryWithAttachments(RAW_ID_WHERE, buildArgs(messageId)) + } + + @Throws(NoSuchMessageException::class) + fun getMessageRecord(messageId: Long): MessageRecord { + rawQueryWithAttachments(RAW_ID_WHERE, arrayOf(messageId.toString() + "")).use { cursor -> + return MmsReader(cursor).getNext() ?: throw NoSuchMessageException("No message for ID: $messageId") + } + } + + fun getMessageRecordOrNull(messageId: Long): MessageRecord? { + rawQueryWithAttachments(RAW_ID_WHERE, buildArgs(messageId)).use { cursor -> + return MmsReader(cursor).firstOrNull() + } + } + + fun getMessages(messageIds: Collection): MmsReader { + val ids = TextUtils.join(",", messageIds) + return mmsReaderFor(rawQueryWithAttachments("$TABLE_NAME.$ID IN ($ids)", null)) + } + + private fun updateMailboxBitmask(id: Long, maskOff: Long, maskOn: Long, threadId: Optional) { + writableDatabase.withinTransaction { db -> + db.execSQL( + """ + UPDATE $TABLE_NAME + SET $TYPE = ($TYPE & ${MessageTypes.TOTAL_MASK - maskOff} | $maskOn ) + WHERE $ID = ? + """.toSingleLine(), + buildArgs(id) + ) + + if (threadId.isPresent) { + threads.updateSnippetTypeSilently(threadId.get()) + } + } + } + + fun markAsOutbox(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_OUTBOX_TYPE, Optional.of(threadId)) + } + + fun markAsForcedSms(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask(messageId, MessageTypes.PUSH_MESSAGE_BIT, MessageTypes.MESSAGE_FORCE_SMS_BIT, Optional.of(threadId)) + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + } + + fun markAsRateLimited(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask(messageId, 0, MessageTypes.MESSAGE_RATE_LIMITED_BIT, Optional.of(threadId)) + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + } + + fun clearRateLimitStatus(ids: Collection) { + writableDatabase.withinTransaction { + for (id in ids) { + val threadId = getThreadIdForMessage(id) + updateMailboxBitmask(id, MessageTypes.MESSAGE_RATE_LIMITED_BIT, 0, Optional.of(threadId)) + } + } + } + + fun markAsPendingInsecureSmsFallback(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_PENDING_INSECURE_SMS_FALLBACK, Optional.of(threadId)) + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + } + + fun markAsSending(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_SENDING_TYPE, Optional.of(threadId)) + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners() + } + + fun markAsSentFailed(messageId: Long) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_SENT_FAILED_TYPE, Optional.of(threadId)) + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners() + } + + fun markAsSent(messageId: Long, secure: Boolean) { + val threadId = getThreadIdForMessage(messageId) + updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_SENT_TYPE or if (secure) MessageTypes.PUSH_MESSAGE_BIT or MessageTypes.SECURE_MESSAGE_BIT else 0, Optional.of(threadId)) + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners() + } + + fun markAsRemoteDelete(messageId: Long) { + var deletedAttachments = false + writableDatabase.withinTransaction { db -> + db.update(TABLE_NAME) + .values( + REMOTE_DELETED to 1, + BODY to null, + QUOTE_BODY to null, + QUOTE_AUTHOR to null, + QUOTE_TYPE to null, + QUOTE_ID to null, + LINK_PREVIEWS to null, + SHARED_CONTACTS to null + ) + .where("$ID = ?", messageId) + .run() + + deletedAttachments = attachments.deleteAttachmentsForMessage(messageId) + mentions.deleteMentionsForMessage(messageId) + messageLog.deleteAllRelatedToMessage(messageId) + reactions.deleteReactions(MessageId(messageId)) + deleteGroupStoryReplies(messageId) + disassociateStoryQuotes(messageId) + + val threadId = getThreadIdForMessage(messageId) + threads.update(threadId, false) + } + + OptimizeMessageSearchIndexJob.enqueue() + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + ApplicationDependencies.getDatabaseObserver().notifyConversationListListeners() + + if (deletedAttachments) { + ApplicationDependencies.getDatabaseObserver().notifyAttachmentObservers() + } + } + + fun markDownloadState(messageId: Long, state: Long) { + writableDatabase + .update(TABLE_NAME) + .values(MMS_STATUS to state) + .where("$ID = ?", messageId) + .run() + + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + } + + fun clearScheduledStatus(threadId: Long, messageId: Long, expiresIn: Long): Boolean { + val rowsUpdated = writableDatabase + .update(TABLE_NAME) + .values( + SCHEDULED_DATE to -1, + DATE_SENT to System.currentTimeMillis(), + DATE_RECEIVED to System.currentTimeMillis(), + EXPIRES_IN to expiresIn + ) + .where("$ID = ? AND $SCHEDULED_DATE != ?", messageId, -1) + .run() + + ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, MessageId(messageId)) + ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId) + + return rowsUpdated > 0 + } + + fun rescheduleMessage(threadId: Long, messageId: Long, time: Long) { + val rowsUpdated = writableDatabase + .update(TABLE_NAME) + .values(SCHEDULED_DATE to time) + .where("$ID = ? AND $SCHEDULED_DATE != ?", messageId, -1) + .run() + + ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId) + ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary() + + if (rowsUpdated == 0) { + Log.w(TAG, "Failed to reschedule messageId=$messageId to new time $time. may have been sent already") + } + } + + fun markAsInsecure(messageId: Long) { + updateMailboxBitmask(messageId, MessageTypes.SECURE_MESSAGE_BIT, 0, Optional.empty()) + } + + fun markUnidentified(messageId: Long, unidentified: Boolean) { + writableDatabase + .update(TABLE_NAME) + .values(UNIDENTIFIED to if (unidentified) 1 else 0) + .where("$ID = ?", messageId) + .run() + } + + @JvmOverloads + fun markExpireStarted(id: Long, startedTimestamp: Long = System.currentTimeMillis()) { + markExpireStarted(setOf(id), startedTimestamp) + } + + fun markExpireStarted(ids: Collection, startedAtTimestamp: Long) { + var threadId: Long = -1 + writableDatabase.withinTransaction { db -> + for (id in ids) { + db.update(TABLE_NAME) + .values(EXPIRE_STARTED to startedAtTimestamp) + .where("$ID = ? AND ($EXPIRE_STARTED = 0 OR $EXPIRE_STARTED > ?)", id, startedAtTimestamp) + .run() + + if (threadId < 0) { + threadId = getThreadIdForMessage(id) + } + } + + threads.update(threadId, false) + } + + notifyConversationListeners(threadId) + } + + fun markAsNotified(id: Long) { + writableDatabase + .update(TABLE_NAME) + .values( + NOTIFIED to 1, + REACTIONS_LAST_SEEN to System.currentTimeMillis() + ) + .where("$ID = ?", id) + .run() + } + + fun setMessagesReadSince(threadId: Long, sinceTimestamp: Long): List { + var query = """ + $THREAD_ID = ? AND + $STORY_TYPE = 0 AND + $PARENT_STORY_ID <= 0 AND + ( + $READ = 0 OR + ( + $REACTIONS_UNREAD = 1 AND + (${getOutgoingTypeClause()}) + ) + ) + """.toSingleLine() + + val args = mutableListOf(threadId.toString()) + + if (sinceTimestamp >= 0L) { + query += " AND $DATE_RECEIVED <= ?" + args += sinceTimestamp.toString() + } + + return setMessagesRead(query, args.toTypedArray()) + } + + fun setGroupStoryMessagesReadSince(threadId: Long, groupStoryId: Long, sinceTimestamp: Long): List { + var query = """ + $THREAD_ID = ? AND + $STORY_TYPE = 0 AND + $PARENT_STORY_ID = ? AND + ( + $READ = 0 OR + ( + $REACTIONS_UNREAD = 1 AND + (${getOutgoingTypeClause()}) + ) + ) + """.toSingleLine() + + val args = mutableListOf(threadId.toString(), groupStoryId.toString()) + + if (sinceTimestamp >= 0L) { + query += " AND $DATE_RECEIVED <= ?" + args += sinceTimestamp.toString() + } + + return setMessagesRead(query, args.toTypedArray()) + } + + fun getStoryTypes(messageIds: List): List { + if (messageIds.isEmpty()) { + return emptyList() + } + + val rawMessageIds: List = messageIds.map { it.id } + val storyTypes: MutableMap = mutableMapOf() + + SqlUtil.buildCollectionQuery(ID, rawMessageIds).forEach { query -> + readableDatabase + .select(ID, STORY_TYPE) + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .use { cursor -> + while (cursor.moveToNext()) { + storyTypes[cursor.requireLong(ID)] = fromCode(cursor.requireInt(STORY_TYPE)) + } + } + } + + return rawMessageIds.map { id: Long -> + if (storyTypes.containsKey(id)) { + storyTypes[id]!! + } else { + StoryType.NONE + } + } + } + + fun setEntireThreadRead(threadId: Long): List { + return setMessagesRead("$THREAD_ID = ? AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0", buildArgs(threadId)) + } + + fun setAllMessagesRead(): List { + return setMessagesRead("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR ($REACTIONS_UNREAD = 1 AND (${getOutgoingTypeClause()})))", null) + } + + private fun setMessagesRead(where: String, arguments: Array?): List { + val releaseChannelId = SignalStore.releaseChannelValues().releaseChannelRecipientId + return writableDatabase.withinTransaction { db -> + val infos = db + .select(ID, RECIPIENT_ID, DATE_SENT, TYPE, EXPIRES_IN, EXPIRE_STARTED, THREAD_ID, STORY_TYPE) + .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_DATE") + .where(where, arguments ?: emptyArray()) + .run() + .readToList { cursor -> + val threadId = cursor.requireLong(THREAD_ID) + val recipientId = RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + val dateSent = cursor.requireLong(DATE_SENT) + val messageId = cursor.requireLong(ID) + val expiresIn = cursor.requireLong(EXPIRES_IN) + val expireStarted = cursor.requireLong(EXPIRE_STARTED) + val syncMessageId = SyncMessageId(recipientId, dateSent) + val expirationInfo = ExpirationInfo(messageId, expiresIn, expireStarted, true) + val storyType = fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)) + + if (recipientId != releaseChannelId) { + MarkedMessageInfo(threadId, syncMessageId, MessageId(messageId), expirationInfo, storyType) + } else { + null + } + } + .filterNotNull() + + db.update("$TABLE_NAME INDEXED BY $INDEX_THREAD_DATE") + .values( + READ to 1, + REACTIONS_UNREAD to 0, + REACTIONS_LAST_SEEN to System.currentTimeMillis() + ) + .where(where, arguments ?: emptyArray()) + .run() + + infos + } + } + + fun getOldestUnreadMentionDetails(threadId: Long): Pair? { + return readableDatabase + .select(RECIPIENT_ID, DATE_RECEIVED) + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $READ = 0 AND $MENTIONS_SELF = 1", threadId) + .orderBy("$DATE_RECEIVED ASC") + .limit(1) + .run() + .readToSingleObject { cursor -> + Pair( + RecipientId.from(cursor.requireLong(RECIPIENT_ID)), + cursor.requireLong(DATE_RECEIVED) + ) + } + } + + fun getUnreadMentionCount(threadId: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $READ = 0 AND $MENTIONS_SELF = 1", threadId) + .run() + .readToSingleInt() + } + + /** + * Trims data related to expired messages. Only intended to be run after a backup restore. + */ + fun trimEntriesForExpiredMessages() { + writableDatabase + .delete(GroupReceiptTable.TABLE_NAME) + .where("${GroupReceiptTable.MMS_ID} NOT IN (SELECT $ID FROM $TABLE_NAME)") + .run() + + readableDatabase + .select(AttachmentTable.ROW_ID, AttachmentTable.UNIQUE_ID) + .from(AttachmentTable.TABLE_NAME) + .where("${AttachmentTable.MMS_ID} NOT IN (SELECT $ID FROM $TABLE_NAME)") + .run() + .forEach { cursor -> + attachments.deleteAttachment(AttachmentId(cursor.requireLong(AttachmentTable.ROW_ID), cursor.requireLong(AttachmentTable.UNIQUE_ID))) + } + + mentions.deleteAbandonedMentions() + + readableDatabase + .select(ThreadTable.ID) + .from(ThreadTable.TABLE_NAME) + .where("${ThreadTable.EXPIRES_IN} > 0") + .run() + .forEach { cursor -> + val id = cursor.requireLong(ThreadTable.ID) + threads.setLastScrolled(id, 0) + threads.update(id, false) + } + } + + fun getNotification(messageId: Long): Optional { + return readableDatabase + .select(RECIPIENT_ID, MMS_CONTENT_LOCATION, MMS_TRANSACTION_ID, SMS_SUBSCRIPTION_ID) + .from(TABLE_NAME) + .where("$ID = ?", messageId) + .run() + .readToSingleObject { cursor -> + MmsNotificationInfo( + from = RecipientId.from(cursor.requireLong(RECIPIENT_ID)), + contentLocation = cursor.requireNonNullString(MMS_CONTENT_LOCATION), + transactionId = cursor.requireNonNullString(MMS_TRANSACTION_ID), + subscriptionId = cursor.requireInt(SMS_SUBSCRIPTION_ID) + ) + } + .toOptional() + } + + @Throws(MmsException::class, NoSuchMessageException::class) + fun getOutgoingMessage(messageId: Long): OutgoingMessage { + return rawQueryWithAttachments(RAW_ID_WHERE, arrayOf(messageId.toString())).readToSingleObject { cursor -> + val associatedAttachments = attachments.getAttachmentsForMessage(messageId) + val mentions = mentions.getMentionsForMessage(messageId) + val outboxType = cursor.requireLong(TYPE) + val body = cursor.requireString(BODY) + val timestamp = cursor.requireLong(DATE_SENT) + val subscriptionId = cursor.requireInt(SMS_SUBSCRIPTION_ID) + val expiresIn = cursor.requireLong(EXPIRES_IN) + val viewOnce = cursor.requireLong(VIEW_ONCE) == 1L + val recipient = Recipient.resolved(RecipientId.from(cursor.requireLong(RECIPIENT_ID))) + val threadId = cursor.requireLong(THREAD_ID) + val distributionType = threads.getDistributionType(threadId) + val storyType = StoryType.fromCode(cursor.requireInt(STORY_TYPE)) + val parentStoryId = ParentStoryId.deserialize(cursor.requireLong(PARENT_STORY_ID)) + val messageRangesData = cursor.requireBlob(MESSAGE_RANGES) + val scheduledDate = cursor.requireLong(SCHEDULED_DATE) + + val quoteId = cursor.requireLong(QUOTE_ID) + val quoteAuthor = cursor.requireLong(QUOTE_AUTHOR) + val quoteText = cursor.requireString(QUOTE_BODY) + val quoteType = cursor.requireInt(QUOTE_TYPE) + val quoteMissing = cursor.requireBoolean(QUOTE_MISSING) + val quoteAttachments: List = associatedAttachments.filter { it.isQuote }.toList() + val quoteMentions: List = parseQuoteMentions(cursor) + val quoteBodyRanges: BodyRangeList? = parseQuoteBodyRanges(cursor) + val quote: QuoteModel? = if (quoteId > 0 && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || quoteAttachments.isNotEmpty())) { + QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText ?: "", quoteMissing, quoteAttachments, quoteMentions, QuoteModel.Type.fromCode(quoteType), quoteBodyRanges) + } else { + null + } + + val contacts: List = getSharedContacts(cursor, associatedAttachments) + val contactAttachments: Set = contacts.mapNotNull { it.avatarAttachment }.toSet() + val previews: List = getLinkPreviews(cursor, associatedAttachments) + val previewAttachments: Set = previews.filter { it.thumbnail.isPresent }.map { it.thumbnail.get() }.toSet() + val attachments: List = associatedAttachments + .filterNot { it.isQuote } + .filterNot { contactAttachments.contains(it) } + .filterNot { previewAttachments.contains(it) } + .sortedWith(DisplayOrderComparator()) + + val mismatchDocument = cursor.requireString(MISMATCHED_IDENTITIES) + val mismatches: Set = if (!TextUtils.isEmpty(mismatchDocument)) { + try { + JsonUtils.fromJson(mismatchDocument, IdentityKeyMismatchSet::class.java).items.toSet() + } catch (e: IOException) { + Log.w(TAG, e) + setOf() + } + } else { + setOf() + } + + val networkDocument = cursor.requireString(NETWORK_FAILURES) + val networkFailures: Set = if (!TextUtils.isEmpty(networkDocument)) { + try { + JsonUtils.fromJson(networkDocument, NetworkFailureSet::class.java).items.toSet() + } catch (e: IOException) { + Log.w(TAG, e) + setOf() + } + } else { + setOf() + } + + if (body != null && (MessageTypes.isGroupQuit(outboxType) || MessageTypes.isGroupUpdate(outboxType))) { + OutgoingMessage.groupUpdateMessage( + recipient = recipient, + groupContext = MessageGroupContext(body, MessageTypes.isGroupV2(outboxType)), + avatar = attachments, + sentTimeMillis = timestamp, + expiresIn = 0, + viewOnce = false, + quote = quote, + contacts = contacts, + previews = previews, + mentions = mentions + ) + } else if (MessageTypes.isExpirationTimerUpdate(outboxType)) { + OutgoingMessage.expirationUpdateMessage( + recipient = recipient, + sentTimeMillis = timestamp, + expiresIn = expiresIn + ) + } else if (MessageTypes.isPaymentsNotification(outboxType)) { + OutgoingMessage.paymentNotificationMessage( + recipient = recipient, + paymentUuid = body!!, + sentTimeMillis = timestamp, + expiresIn = expiresIn + ) + } else if (MessageTypes.isPaymentsRequestToActivate(outboxType)) { + OutgoingMessage.requestToActivatePaymentsMessage( + recipient = recipient, + sentTimeMillis = timestamp, + expiresIn = expiresIn + ) + } else if (MessageTypes.isPaymentsActivated(outboxType)) { + OutgoingMessage.paymentsActivatedMessage( + recipient = recipient, + sentTimeMillis = timestamp, + expiresIn = expiresIn + ) + } else { + val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(outboxType)) { + GiftBadge.parseFrom(Base64.decode(body)) + } else { + null + } + + val messageRanges: BodyRangeList? = if (messageRangesData != null) { + try { + BodyRangeList.parseFrom(messageRangesData) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, "Error parsing message ranges", e) + null + } + } else { + null + } + + OutgoingMessage( + recipient = recipient, + body = body, + attachments = attachments, + timestamp = timestamp, + subscriptionId = subscriptionId, + expiresIn = expiresIn, + viewOnce = viewOnce, + distributionType = distributionType, + storyType = storyType, + parentStoryId = parentStoryId, + isStoryReaction = MessageTypes.isStoryReaction(outboxType), + quote = quote, + contacts = contacts, + previews = previews, + mentions = mentions, + networkFailures = networkFailures, + mismatches = mismatches, + giftBadge = giftBadge, + isSecure = MessageTypes.isSecureType(outboxType), + bodyRanges = messageRanges, + scheduledDate = scheduledDate + ) + } + } ?: throw NoSuchMessageException("No record found for id: $messageId") + } + + @Throws(MmsException::class) + private fun insertMessageInbox( + retrieved: IncomingMediaMessage, + contentLocation: String, + candidateThreadId: Long, + mailbox: Long + ): Optional { + val threadId = if (candidateThreadId == -1L || retrieved.isGroupMessage) { + getThreadIdFor(retrieved) + } else { + candidateThreadId + } + + if (retrieved.isPushMessage && isDuplicate(retrieved, threadId)) { + Log.w(TAG, "Ignoring duplicate media message (" + retrieved.sentTimeMillis + ")") + return Optional.empty() + } + + val silentUpdate = mailbox and MessageTypes.GROUP_UPDATE_BIT > 0 + + val contentValues = contentValuesOf( + DATE_SENT to retrieved.sentTimeMillis, + DATE_SERVER to retrieved.serverTimeMillis, + RECIPIENT_ID to retrieved.from!!.serialize(), + TYPE to mailbox, + MMS_MESSAGE_TYPE to PduHeaders.MESSAGE_TYPE_RETRIEVE_CONF, + THREAD_ID to threadId, + MMS_CONTENT_LOCATION to contentLocation, + MMS_STATUS to MmsStatus.DOWNLOAD_INITIALIZED, + DATE_RECEIVED to if (retrieved.isPushMessage) retrieved.receivedTimeMillis else generatePduCompatTimestamp(retrieved.receivedTimeMillis), + SMS_SUBSCRIPTION_ID to retrieved.subscriptionId, + EXPIRES_IN to retrieved.expiresIn, + VIEW_ONCE to if (retrieved.isViewOnce) 1 else 0, + STORY_TYPE to retrieved.storyType.code, + PARENT_STORY_ID to if (retrieved.parentStoryId != null) retrieved.parentStoryId.serialize() else 0, + READ to if (silentUpdate || retrieved.isExpirationUpdate) 1 else 0, + UNIDENTIFIED to retrieved.isUnidentified, + SERVER_GUID to retrieved.serverGuid + ) + + val quoteAttachments: MutableList = mutableListOf() + if (retrieved.quote != null) { + contentValues.put(QUOTE_ID, retrieved.quote.id) + contentValues.put(QUOTE_BODY, retrieved.quote.text) + contentValues.put(QUOTE_AUTHOR, retrieved.quote.author.serialize()) + contentValues.put(QUOTE_TYPE, retrieved.quote.type.code) + contentValues.put(QUOTE_MISSING, if (retrieved.quote.isOriginalMissing) 1 else 0) + + val quoteBodyRanges: BodyRangeList.Builder = retrieved.quote.bodyRanges?.toBuilder() ?: BodyRangeList.newBuilder() + val mentionsList = MentionUtil.mentionsToBodyRangeList(retrieved.quote.mentions) + + if (mentionsList != null) { + quoteBodyRanges.addAllRanges(mentionsList.rangesList) + } + + if (quoteBodyRanges.rangesCount > 0) { + contentValues.put(QUOTE_BODY_RANGES, quoteBodyRanges.build().toByteArray()) + } + + quoteAttachments += retrieved.quote.attachments + } + + val messageId = insertMediaMessage( + threadId = threadId, + body = retrieved.body, + attachments = retrieved.attachments, + quoteAttachments = quoteAttachments, + sharedContacts = retrieved.sharedContacts, + linkPreviews = retrieved.linkPreviews, + mentions = retrieved.mentions, + messageRanges = retrieved.messageRanges, + contentValues = contentValues, + insertListener = null, + updateThread = retrieved.storyType === StoryType.NONE, + unarchive = true + ) + + val isNotStoryGroupReply = retrieved.parentStoryId == null || !retrieved.parentStoryId.isGroupReply() + + if (!MessageTypes.isPaymentsActivated(mailbox) && !MessageTypes.isPaymentsRequestToActivate(mailbox) && !MessageTypes.isExpirationTimerUpdate(mailbox) && !retrieved.storyType.isStory && isNotStoryGroupReply) { + val incrementUnreadMentions = retrieved.mentions.isNotEmpty() && retrieved.mentions.any { it.recipientId == Recipient.self().id } + threads.incrementUnread(threadId, 1, if (incrementUnreadMentions) 1 else 0) + threads.update(threadId, true) + } + + notifyConversationListeners(threadId) + + if (retrieved.storyType.isStory) { + ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(threads.getRecipientIdForThreadId(threadId)!!) + } + + return Optional.of(InsertResult(messageId, threadId)) + } + + @Throws(MmsException::class) + fun insertMessageInbox( + retrieved: IncomingMediaMessage, + contentLocation: String, + threadId: Long + ): Optional { + var type = MessageTypes.BASE_INBOX_TYPE + + if (retrieved.isPushMessage) { + type = type or MessageTypes.PUSH_MESSAGE_BIT + } + + if (retrieved.isExpirationUpdate) { + type = type or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT + } + + if (retrieved.isPaymentsNotification) { + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION + } + + if (retrieved.isActivatePaymentsRequest) { + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST + } + + if (retrieved.isPaymentsActivated) { + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED + } + + return insertMessageInbox(retrieved, contentLocation, threadId, type) + } + + @Throws(MmsException::class) + fun insertSecureDecryptedMessageInbox(retrieved: IncomingMediaMessage, threadId: Long): Optional { + var type = MessageTypes.BASE_INBOX_TYPE or MessageTypes.SECURE_MESSAGE_BIT + var hasSpecialType = false + + if (retrieved.isPushMessage) { + type = type or MessageTypes.PUSH_MESSAGE_BIT + } + + if (retrieved.isExpirationUpdate) { + type = type or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT + } + + if (retrieved.isStoryReaction) { + type = type or MessageTypes.SPECIAL_TYPE_STORY_REACTION + hasSpecialType = true + } + + if (retrieved.giftBadge != null) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_GIFT_BADGE + hasSpecialType = true + } + + if (retrieved.isPaymentsNotification) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION + hasSpecialType = true + } + + if (retrieved.isActivatePaymentsRequest) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST + hasSpecialType = true + } + + if (retrieved.isPaymentsActivated) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED + hasSpecialType = true + } + + return insertMessageInbox(retrieved, "", threadId, type) + } + + fun insertMessageInbox(notification: NotificationInd, subscriptionId: Int): Pair { + Log.i(TAG, "Message received type: " + notification.messageType) + + val threadId = getThreadIdFor(notification) + + val recipientId: String = if (notification.from != null) { + Recipient.external(context, Util.toIsoString(notification.from.textString)).id.serialize() + } else { + RecipientId.UNKNOWN.serialize() + } + + val messageId = writableDatabase + .insertInto(TABLE_NAME) + .values( + MMS_CONTENT_LOCATION to notification.contentLocation.toIsoString(), + DATE_SENT to System.currentTimeMillis(), + MMS_EXPIRY to if (notification.expiry != -1L) notification.expiry else null, + MMS_MESSAGE_SIZE to if (notification.messageSize != -1L) notification.messageSize else null, + MMS_TRANSACTION_ID to notification.transactionId.toIsoString(), + MMS_MESSAGE_TYPE to if (notification.messageType != 0) notification.messageType else null, + RECIPIENT_ID to recipientId, + TYPE to MessageTypes.BASE_INBOX_TYPE, + THREAD_ID to threadId, + MMS_STATUS to MmsStatus.DOWNLOAD_INITIALIZED, + DATE_RECEIVED to generatePduCompatTimestamp(System.currentTimeMillis()), + READ to if (Util.isDefaultSmsProvider(context)) 0 else 1, + SMS_SUBSCRIPTION_ID to subscriptionId + ) + .run() + + return Pair(messageId, threadId) + } + + fun insertChatSessionRefreshedMessage(recipientId: RecipientId, senderDeviceId: Long, sentTimestamp: Long): InsertResult { + val threadId = threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + var type = MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT + type = type and MessageTypes.TOTAL_MASK - MessageTypes.ENCRYPTION_MASK or MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT + + val messageId = writableDatabase + .insertInto(TABLE_NAME) + .values( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to senderDeviceId, + DATE_RECEIVED to System.currentTimeMillis(), + DATE_SENT to sentTimestamp, + DATE_SERVER to -1, + READ to 0, + TYPE to type, + THREAD_ID to threadId + ) + .run() + + threads.incrementUnread(threadId, 1, 0) + threads.update(threadId, true) + + notifyConversationListeners(threadId) + TrimThreadJob.enqueueAsync(threadId) + + return InsertResult(messageId, threadId) + } + + fun insertBadDecryptMessage(recipientId: RecipientId, senderDevice: Int, sentTimestamp: Long, receivedTimestamp: Long, threadId: Long) { + writableDatabase + .insertInto(TABLE_NAME) + .values( + RECIPIENT_ID to recipientId.serialize(), + RECIPIENT_DEVICE_ID to senderDevice, + DATE_SENT to sentTimestamp, + DATE_RECEIVED to receivedTimestamp, + DATE_SERVER to -1, + READ to 0, + TYPE to MessageTypes.BAD_DECRYPT_TYPE, + THREAD_ID to threadId + ) + .run() + + threads.incrementUnread(threadId, 1, 0) + threads.update(threadId, true) + + notifyConversationListeners(threadId) + TrimThreadJob.enqueueAsync(threadId) + } + + fun markIncomingNotificationReceived(threadId: Long) { + notifyConversationListeners(threadId) + + if (Util.isDefaultSmsProvider(context)) { + threads.incrementUnread(threadId, 1, 0) + } + + threads.update(threadId, true) + TrimThreadJob.enqueueAsync(threadId) + } + + fun markGiftRedemptionCompleted(messageId: Long) { + markGiftRedemptionState(messageId, GiftBadge.RedemptionState.REDEEMED) + } + + fun markGiftRedemptionStarted(messageId: Long) { + markGiftRedemptionState(messageId, GiftBadge.RedemptionState.STARTED) + } + + fun markGiftRedemptionFailed(messageId: Long) { + markGiftRedemptionState(messageId, GiftBadge.RedemptionState.FAILED) + } + + private fun markGiftRedemptionState(messageId: Long, redemptionState: GiftBadge.RedemptionState) { + var updated = false + var threadId: Long = -1 + + writableDatabase.withinTransaction { db -> + db.select(BODY, THREAD_ID) + .from(TABLE_NAME) + .where("($TYPE & ${MessageTypes.SPECIAL_TYPES_MASK} = ${MessageTypes.SPECIAL_TYPE_GIFT_BADGE}) AND $ID = ?", messageId) + .run() + .use { cursor -> + if (cursor.moveToFirst()) { + val giftBadge = GiftBadge.parseFrom(Base64.decode(cursor.requireNonNullString(BODY))) + val updatedBadge = giftBadge.toBuilder().setRedemptionState(redemptionState).build() + + updated = db + .update(TABLE_NAME) + .values(BODY to Base64.encodeBytes(updatedBadge.toByteArray())) + .where("$ID = ?", messageId) + .run() > 0 + + threadId = cursor.requireLong(THREAD_ID) + } + } + } + + if (updated) { + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(MessageId(messageId)) + notifyConversationListeners(threadId) + } + } + + @Throws(MmsException::class) + fun insertMessageOutbox( + message: OutgoingMessage, + threadId: Long, + forceSms: Boolean, + insertListener: InsertListener? + ): Long { + return insertMessageOutbox( + message = message, + threadId = threadId, + forceSms = forceSms, + defaultReceiptStatus = GroupReceiptTable.STATUS_UNDELIVERED, + insertListener = insertListener + ) + } + + @Throws(MmsException::class) + fun insertMessageOutbox( + message: OutgoingMessage, + threadId: Long, + forceSms: Boolean, + defaultReceiptStatus: Int, + insertListener: InsertListener? + ): Long { + var type = MessageTypes.BASE_SENDING_TYPE + var hasSpecialType = false + + if (message.isSecure) { + type = type or (MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT) + } + + if (forceSms) { + type = type or MessageTypes.MESSAGE_FORCE_SMS_BIT + } + + if (message.isSecure) { + type = type or (MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT) + } else if (message.isEndSession) { + type = type or MessageTypes.END_SESSION_BIT + } + + if (message.isIdentityVerified) { + type = type or MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT + } else if (message.isIdentityDefault) { + type = type or MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT + } + + if (message.isGroup) { + if (message.isV2Group) { + type = type or (MessageTypes.GROUP_V2_BIT or MessageTypes.GROUP_UPDATE_BIT) + + if (message.isJustAGroupLeave) { + type = type or MessageTypes.GROUP_LEAVE_BIT + } + } else { + val properties = message.requireGroupV1Properties() + + if (properties.isUpdate) { + type = type or MessageTypes.GROUP_UPDATE_BIT + } else if (properties.isQuit) { + type = type or MessageTypes.GROUP_LEAVE_BIT + } + } + } + + if (message.isExpirationUpdate) { + type = type or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT + } + + if (message.isStoryReaction) { + type = type or MessageTypes.SPECIAL_TYPE_STORY_REACTION + hasSpecialType = true + } + + if (message.giftBadge != null) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_GIFT_BADGE + hasSpecialType = true + } + + if (message.isPaymentsNotification) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION + hasSpecialType = true + } + + if (message.isRequestToActivatePayments) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST + hasSpecialType = true + } + + if (message.isPaymentsActivated) { + if (hasSpecialType) { + throw MmsException("Cannot insert message with multiple special types.") + } + type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED + hasSpecialType = true + } + + val earlyDeliveryReceipts: Map = earlyDeliveryReceiptCache.remove(message.sentTimeMillis) + + if (earlyDeliveryReceipts.isNotEmpty()) { + Log.w(TAG, "Found early delivery receipts for " + message.sentTimeMillis + ". Applying them.") + } + + val contentValues = ContentValues() + contentValues.put(DATE_SENT, message.sentTimeMillis) + contentValues.put(MMS_MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ) + contentValues.put(TYPE, type) + contentValues.put(THREAD_ID, threadId) + contentValues.put(READ, 1) + contentValues.put(DATE_RECEIVED, System.currentTimeMillis()) + contentValues.put(SMS_SUBSCRIPTION_ID, message.subscriptionId) + contentValues.put(EXPIRES_IN, message.expiresIn) + contentValues.put(VIEW_ONCE, message.isViewOnce) + contentValues.put(RECIPIENT_ID, message.recipient.id.serialize()) + contentValues.put(DELIVERY_RECEIPT_COUNT, earlyDeliveryReceipts.values.sumOf { it.count }) + contentValues.put(RECEIPT_TIMESTAMP, earlyDeliveryReceipts.values.map { it.timestamp }.maxOrNull() ?: -1L) + contentValues.put(STORY_TYPE, message.storyType.code) + contentValues.put(PARENT_STORY_ID, if (message.parentStoryId != null) message.parentStoryId.serialize() else 0) + contentValues.put(SCHEDULED_DATE, message.scheduledDate) + + if (message.recipient.isSelf && hasAudioAttachment(message.attachments)) { + contentValues.put(VIEWED_RECEIPT_COUNT, 1L) + } + + val quoteAttachments: MutableList = mutableListOf() + + if (message.outgoingQuote != null) { + val updated = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.outgoingQuote.text, message.outgoingQuote.mentions) + + contentValues.put(QUOTE_ID, message.outgoingQuote.id) + contentValues.put(QUOTE_AUTHOR, message.outgoingQuote.author.serialize()) + contentValues.put(QUOTE_BODY, updated.bodyAsString) + contentValues.put(QUOTE_TYPE, message.outgoingQuote.type.code) + contentValues.put(QUOTE_MISSING, if (message.outgoingQuote.isOriginalMissing) 1 else 0) + + val adjustedQuoteBodyRanges = message.outgoingQuote.bodyRanges.adjustBodyRanges(updated.bodyAdjustments) + val quoteBodyRanges: BodyRangeList.Builder = if (adjustedQuoteBodyRanges != null) { + adjustedQuoteBodyRanges.toBuilder() + } else { + BodyRangeList.newBuilder() + } + + val mentionsList = MentionUtil.mentionsToBodyRangeList(updated.mentions) + if (mentionsList != null) { + quoteBodyRanges.addAllRanges(mentionsList.rangesList) + } + + if (quoteBodyRanges.rangesCount > 0) { + contentValues.put(QUOTE_BODY_RANGES, quoteBodyRanges.build().toByteArray()) + } + + quoteAttachments += message.outgoingQuote.attachments + } + + val updatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.body, message.mentions) + val bodyRanges = message.bodyRanges.adjustBodyRanges(updatedBodyAndMentions.bodyAdjustments) + val messageId = insertMediaMessage( + threadId = threadId, + body = updatedBodyAndMentions.bodyAsString, + attachments = message.attachments, + quoteAttachments = quoteAttachments, + sharedContacts = message.sharedContacts, + linkPreviews = message.linkPreviews, + mentions = updatedBodyAndMentions.mentions, + messageRanges = bodyRanges, + contentValues = contentValues, + insertListener = insertListener, + updateThread = false, + unarchive = false + ) + + if (message.recipient.isGroup) { + val members: MutableSet = mutableSetOf() + + if (message.isGroupUpdate && message.isV2Group) { + members += message.requireGroupV2Properties().allActivePendingAndRemovedMembers + .distinct() + .map { uuid -> RecipientId.from(ServiceId.from(uuid)) } + .toList() + + members -= Recipient.self().id + } else { + members += groups.getGroupMembers(message.recipient.requireGroupId(), GroupTable.MemberSet.FULL_MEMBERS_EXCLUDING_SELF).map { it.id } + } + + groupReceipts.insert(members, messageId, defaultReceiptStatus, message.sentTimeMillis) + + for (recipientId in earlyDeliveryReceipts.keys) { + groupReceipts.update(recipientId, messageId, GroupReceiptTable.STATUS_DELIVERED, -1) + } + } else if (message.recipient.isDistributionList) { + val members = distributionLists.getMembers(message.recipient.requireDistributionListId()) + + groupReceipts.insert(members, messageId, defaultReceiptStatus, message.sentTimeMillis) + + for (recipientId in earlyDeliveryReceipts.keys) { + groupReceipts.update(recipientId, messageId, GroupReceiptTable.STATUS_DELIVERED, -1) + } + } + + threads.updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId) + + if (!message.storyType.isStory) { + if (message.outgoingQuote == null) { + ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, MessageId(messageId)) + } else { + ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId) + } + + if (message.scheduledDate != -1L) { + ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId) + } + } else { + ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(message.recipient.id) + } + + notifyConversationListListeners() + + if (!message.isIdentityVerified && !message.isIdentityDefault) { + TrimThreadJob.enqueueAsync(threadId) + } + + return messageId + } + + private fun hasAudioAttachment(attachments: List): Boolean { + return attachments.any { MediaUtil.isAudio(it) } + } + + @Throws(MmsException::class) + private fun insertMediaMessage( + threadId: Long, + body: String?, + attachments: List, + quoteAttachments: List, + sharedContacts: List, + linkPreviews: List, + mentions: List, + messageRanges: BodyRangeList?, + contentValues: ContentValues, + insertListener: InsertListener?, + updateThread: Boolean, + unarchive: Boolean + ): Long { + val mentionsSelf = mentions.any { Recipient.resolved(it.recipientId).isSelf } + val allAttachments: MutableList = mutableListOf() + + allAttachments += attachments + allAttachments += sharedContacts.mapNotNull { it.avatarAttachment } + allAttachments += linkPreviews.mapNotNull { it.thumbnail.orElse(null) } + + contentValues.put(BODY, body) + contentValues.put(MENTIONS_SELF, if (mentionsSelf) 1 else 0) + if (messageRanges != null) { + contentValues.put(MESSAGE_RANGES, messageRanges.toByteArray()) + } + + val messageId = writableDatabase.withinTransaction { db -> + val messageId = db.insert(TABLE_NAME, null, contentValues) + + SignalDatabase.mentions.insert(threadId, messageId, mentions) + + val insertedAttachments = SignalDatabase.attachments.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments) + val serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts) + val serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews) + + if (!TextUtils.isEmpty(serializedContacts)) { + val rows = db + .update(TABLE_NAME) + .values(SHARED_CONTACTS to serializedContacts) + .where("$ID = ?", messageId) + .run() + + if (rows <= 0) { + Log.w(TAG, "Failed to update message with shared contact data.") + } + } + + if (!TextUtils.isEmpty(serializedPreviews)) { + val rows = db + .update(TABLE_NAME) + .values(LINK_PREVIEWS to serializedPreviews) + .where("$ID = ?", messageId) + .run() + + if (rows <= 0) { + Log.w(TAG, "Failed to update message with link preview data.") + } + } + + messageId + } + + insertListener?.onComplete() + + val contentValuesThreadId = contentValues.getAsLong(THREAD_ID) + + if (updateThread) { + threads.setLastScrolled(contentValuesThreadId, 0) + threads.update(threadId, unarchive) + } + + return messageId + } + + fun deleteMessage(messageId: Long): Boolean { + val threadId = getThreadIdForMessage(messageId) + return deleteMessage(messageId, threadId) + } + + fun deleteMessage(messageId: Long, notify: Boolean): Boolean { + val threadId = getThreadIdForMessage(messageId) + return deleteMessage(messageId, threadId, notify) + } + + fun deleteMessage(messageId: Long, threadId: Long): Boolean { + return deleteMessage(messageId, threadId, true) + } + + private fun deleteMessage(messageId: Long, threadId: Long, notify: Boolean): Boolean { + Log.d(TAG, "deleteMessage($messageId)") + + attachments.deleteAttachmentsForMessage(messageId) + groupReceipts.deleteRowsForMessage(messageId) + mentions.deleteMentionsForMessage(messageId) + + writableDatabase + .delete(TABLE_NAME) + .where("$ID = ?", messageId) + .run() + + threads.setLastScrolled(threadId, 0) + val threadDeleted = threads.update(threadId, false) + + if (notify) { + notifyConversationListeners(threadId) + notifyStickerListeners() + notifyStickerPackListeners() + OptimizeMessageSearchIndexJob.enqueue() + } + + return threadDeleted + } + + fun deleteScheduledMessage(messageId: Long) { + Log.d(TAG, "deleteScheduledMessage($messageId)") + + val threadId = getThreadIdForMessage(messageId) + + writableDatabase.withinTransaction { db -> + val rowsUpdated = db + .update(TABLE_NAME) + .values( + SCHEDULED_DATE to -1, + DATE_SENT to System.currentTimeMillis(), + DATE_RECEIVED to System.currentTimeMillis() + ) + .where("$ID = ? AND $SCHEDULED_DATE != ?", messageId, -1) + .run() + + if (rowsUpdated > 0) { + deleteMessage(messageId, threadId) + } else { + Log.w(TAG, "tried to delete scheduled message but it may have already been sent") + } + } + + ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary() + ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId) + } + + fun deleteScheduledMessages(recipientId: RecipientId) { + Log.d(TAG, "deleteScheduledMessages($recipientId)") + + val threadId: Long = threads.getThreadIdFor(recipientId) ?: return Log.i(TAG, "No thread exists for $recipientId") + + writableDatabase.withinTransaction { + val scheduledMessages = getScheduledMessagesInThread(threadId) + for (record in scheduledMessages) { + deleteScheduledMessage(record.id) + } + } + } + + fun deleteThread(threadId: Long) { + Log.d(TAG, "deleteThread($threadId)") + deleteThreads(setOf(threadId)) + } + + private fun getSerializedSharedContacts(insertedAttachmentIds: Map, contacts: List): String? { + if (contacts.isEmpty()) { + return null + } + + val sharedContactJson = JSONArray() + + for (contact in contacts) { + try { + val attachmentId: AttachmentId? = if (contact.avatarAttachment != null) { + insertedAttachmentIds[contact.avatarAttachment] + } else { + null + } + + val updatedAvatar = Contact.Avatar( + attachmentId, + contact.avatarAttachment, + contact.avatar != null && contact.avatar!!.isProfile + ) + + val updatedContact = Contact(contact, updatedAvatar) + + sharedContactJson.put(JSONObject(updatedContact.serialize())) + } catch (e: JSONException) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) + } + } + + return sharedContactJson.toString() + } + + private fun getSerializedLinkPreviews(insertedAttachmentIds: Map, previews: List): String? { + if (previews.isEmpty()) { + return null + } + + val linkPreviewJson = JSONArray() + + for (preview in previews) { + try { + val attachmentId: AttachmentId? = if (preview.thumbnail.isPresent) { + insertedAttachmentIds[preview.thumbnail.get()] + } else { + null + } + + val updatedPreview = LinkPreview(preview.url, preview.title, preview.description, preview.date, attachmentId) + linkPreviewJson.put(JSONObject(updatedPreview.serialize())) + } catch (e: JSONException) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) + } catch (e: IOException) { + Log.w(TAG, "Failed to serialize shared contact. Skipping it.", e) + } + } + + return linkPreviewJson.toString() + } + + private fun isDuplicate(message: IncomingMediaMessage, threadId: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$DATE_SENT = ? AND $RECIPIENT_ID = ? AND $THREAD_ID = ?", message.sentTimeMillis, message.from!!.serialize(), threadId) + .run() + } + + private fun isDuplicate(message: IncomingTextMessage, threadId: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$DATE_SENT = ? AND $RECIPIENT_ID = ? AND $THREAD_ID = ?", message.sentTimestampMillis, message.sender.serialize(), threadId) + .run() + } + + fun isSent(messageId: Long): Boolean { + val type = readableDatabase + .select(TYPE) + .from(TABLE_NAME) + .where("$ID = ?", messageId) + .run() + .readToSingleLong() + + return MessageTypes.isSentType(type) + } + + fun getProfileChangeDetailsRecords(threadId: Long, afterTimestamp: Long): List { + val cursor = readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $DATE_RECEIVED >= ? AND $TYPE = ?", threadId, afterTimestamp, MessageTypes.PROFILE_CHANGE_TYPE) + .orderBy("$ID DESC") + .run() + + return mmsReaderFor(cursor).use { reader -> + reader.filterNotNull() + } + } + fun getAllRateLimitedMessageIds(): Set { + val db = databaseHelper.signalReadableDatabase + val where = "(" + TYPE + " & " + MessageTypes.TOTAL_MASK + " & " + MessageTypes.MESSAGE_RATE_LIMITED_BIT + ") > 0" + val ids: MutableSet = HashSet() + db.query(TABLE_NAME, arrayOf(ID), where, null, null, null, null).use { cursor -> + while (cursor.moveToNext()) { + ids.add(CursorUtil.requireLong(cursor, ID)) + } + } + return ids + } + + fun getUnexportedInsecureMessages(limit: Int): Cursor { + return rawQueryWithAttachments( + projection = appendArg(MMS_PROJECTION_WITH_ATTACHMENTS, EXPORT_STATE), + where = "${getInsecureMessageClause()} AND NOT $EXPORTED", + arguments = null, + reverse = false, + limit = limit.toLong() + ) + } + + fun getUnexportedInsecureMessagesEstimatedSize(): Long { + val bodyTextSize: Long = readableDatabase + .select("SUM(LENGTH($BODY))") + .from(TABLE_NAME) + .where("${getInsecureMessageClause()} AND $EXPORTED < ?", MessageExportStatus.EXPORTED) + .run() + .readToSingleLong() + + val fileSize: Long = readableDatabase.rawQuery( + """ + SELECT + SUM(${AttachmentTable.TABLE_NAME}.${AttachmentTable.SIZE}) AS s + FROM + $TABLE_NAME INNER JOIN ${AttachmentTable.TABLE_NAME} ON $TABLE_NAME.$ID = ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MMS_ID} + WHERE + ${getInsecureMessageClause()} AND $EXPORTED < ${MessageExportStatus.EXPORTED.serialize()} + """.toSingleLine(), + null + ).readToSingleLong() + + return bodyTextSize + fileSize + } + + fun deleteExportedMessages() { + writableDatabase.withinTransaction { db -> + val threadsToUpdate: List = db + .query(TABLE_NAME, arrayOf(THREAD_ID), "$EXPORTED = ?", buildArgs(MessageExportStatus.EXPORTED), THREAD_ID, null, null, null) + .readToList { it.requireLong(THREAD_ID) } + + db.delete(TABLE_NAME) + .where("$EXPORTED = ?", MessageExportStatus.EXPORTED) + .run() + + for (threadId in threadsToUpdate) { + threads.update(threadId, false) + } + + attachments.deleteAbandonedAttachmentFiles() + } + + OptimizeMessageSearchIndexJob.enqueue() + } + + fun deleteThreads(threadIds: Set) { + Log.d(TAG, "deleteThreads(count: ${threadIds.size})") + + writableDatabase.withinTransaction { db -> + SqlUtil.buildCollectionQuery(THREAD_ID, threadIds).forEach { query -> + db.select(ID) + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .forEach { cursor -> + deleteMessage(cursor.requireLong(ID), false) + } + } + } + + notifyConversationListeners(threadIds) + notifyStickerListeners() + notifyStickerPackListeners() + OptimizeMessageSearchIndexJob.enqueue() + } + + fun deleteMessagesInThreadBeforeDate(threadId: Long, date: Long): Int { + return writableDatabase + .delete(TABLE_NAME) + .where("$THREAD_ID = ? AND $DATE_RECEIVED < $date", threadId) + .run() + } + + fun deleteAbandonedMessages(): Int { + val deletes = writableDatabase + .delete(TABLE_NAME) + .where("$THREAD_ID NOT IN (SELECT _id FROM ${ThreadTable.TABLE_NAME})") + .run() + + if (deletes > 0) { + Log.i(TAG, "Deleted $deletes abandoned messages") + } + + return deletes + } + + fun deleteRemotelyDeletedStory(messageId: Long) { + if (readableDatabase.exists(TABLE_NAME).where("$ID = ? AND $REMOTE_DELETED = ?", messageId, 1).run()) { + deleteMessage(messageId) + } else { + Log.i(TAG, "Unable to delete remotely deleted story: $messageId") + } + } + + private fun getMessagesInThreadAfterInclusive(threadId: Long, timestamp: Long, limit: Long): List { + val where = "$TABLE_NAME.$THREAD_ID = ? AND $TABLE_NAME.$DATE_RECEIVED >= ? AND $TABLE_NAME.$SCHEDULED_DATE = -1" + val args = buildArgs(threadId, timestamp) + + return mmsReaderFor(rawQueryWithAttachments(where, args, false, limit)).use { reader -> + reader.filterNotNull() + } + } + + fun deleteAllThreads() { + Log.d(TAG, "deleteAllThreads()") + + attachments.deleteAllAttachments() + groupReceipts.deleteAllRows() + mentions.deleteAllMentions() + writableDatabase.delete(TABLE_NAME).run() + + OptimizeMessageSearchIndexJob.enqueue() + } + + fun getNearestExpiringViewOnceMessage(): ViewOnceExpirationInfo? { + val query = """ + SELECT + $TABLE_NAME.$ID, + $VIEW_ONCE, + $DATE_RECEIVED + FROM + $TABLE_NAME INNER JOIN ${AttachmentTable.TABLE_NAME} ON $TABLE_NAME.$ID = ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MMS_ID} + WHERE + $VIEW_ONCE > 0 AND + (${AttachmentTable.DATA} NOT NULL OR ${AttachmentTable.TRANSFER_STATE} != ?) + """.toSingleLine() + + val args = buildArgs(AttachmentTable.TRANSFER_PROGRESS_DONE) + + var info: ViewOnceExpirationInfo? = null + var nearestExpiration = Long.MAX_VALUE + + readableDatabase.rawQuery(query, args).forEach { cursor -> + val id = cursor.requireLong(ID) + val dateReceived = cursor.requireLong(DATE_RECEIVED) + val expiresAt = dateReceived + ViewOnceUtil.MAX_LIFESPAN + + if (info == null || expiresAt < nearestExpiration) { + info = ViewOnceExpirationInfo(id, dateReceived) + nearestExpiration = expiresAt + } + } + + return info + } + + /** + * The number of change number messages in the thread. + * Currently only used for tests. + */ + @VisibleForTesting + fun getChangeNumberMessageCount(recipientId: RecipientId): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$RECIPIENT_ID = ? AND $TYPE = ?", recipientId, MessageTypes.CHANGE_NUMBER_TYPE) + .run() + .readToSingleInt() + } + + fun beginTransaction(): SQLiteDatabase { + writableDatabase.beginTransaction() + return writableDatabase + } + + fun setTransactionSuccessful() { + writableDatabase.setTransactionSuccessful() + } + + fun endTransaction() { + writableDatabase.endTransaction() + } + + @VisibleForTesting + fun collapseJoinRequestEventsIfPossible(threadId: Long, message: IncomingGroupUpdateMessage): Optional { + var result: InsertResult? = null + + writableDatabase.withinTransaction { db -> + mmsReaderFor(getConversation(threadId, 0, 2)).use { reader -> + val latestMessage = reader.getNext() + + if (latestMessage != null && latestMessage.isGroupV2) { + val changeEditor = message.changeEditor + + if (changeEditor.isPresent && latestMessage.isGroupV2JoinRequest(changeEditor.get())) { + val secondLatestMessage = reader.getNext() + + val id: Long + val encodedBody: String + + if (secondLatestMessage != null && secondLatestMessage.isGroupV2JoinRequest(changeEditor.get())) { + id = secondLatestMessage.id + encodedBody = MessageRecord.createNewContextWithAppendedDeleteJoinRequest(secondLatestMessage, message.changeRevision, changeEditor.get()) + deleteMessage(latestMessage.id) + } else { + id = latestMessage.id + encodedBody = MessageRecord.createNewContextWithAppendedDeleteJoinRequest(latestMessage, message.changeRevision, changeEditor.get()) + } + + db.update(TABLE_NAME) + .values(BODY to encodedBody) + .where("$ID = ?", id) + .run() + + result = InsertResult(id, threadId) + } + } + } + } + + return result.toOptional() + } + + private fun getOutgoingTypeClause(): String { + val segments: MutableList = ArrayList(MessageTypes.OUTGOING_MESSAGE_TYPES.size) + + for (outgoingMessageType in MessageTypes.OUTGOING_MESSAGE_TYPES) { + segments.add("($TABLE_NAME.$TYPE & ${MessageTypes.BASE_TYPE_MASK} = $outgoingMessageType)") + } + + return segments.joinToString(" OR ") + } + + fun getInsecureMessageCount(): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where(getInsecureMessageClause()) + .run() + .readToSingleInt() + } + + fun getInsecureMessageSentCount(threadId: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $outgoingInsecureMessageClause AND $DATE_SENT > ?", threadId, (System.currentTimeMillis() - InsightsConstants.PERIOD_IN_MILLIS)) + .run() + .readToSingleInt() + } + + fun getSecureMessageCount(threadId: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$secureMessageClause AND $THREAD_ID = ?", threadId) + .run() + .readToSingleInt() + } + + fun getOutgoingSecureMessageCount(threadId: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$outgoingSecureMessageClause AND $THREAD_ID = ? AND ($TYPE & ${MessageTypes.GROUP_LEAVE_BIT} = 0 OR $TYPE & ${MessageTypes.GROUP_V2_BIT} = ${MessageTypes.GROUP_V2_BIT})", threadId) + .run() + .readToSingleInt() + } + + fun getInsecureMessageCountForInsights(): Int { + return getMessageCountForRecipientsAndType(outgoingInsecureMessageClause) + } + + fun getSecureMessageCountForInsights(): Int { + return getMessageCountForRecipientsAndType(outgoingSecureMessageClause) + } + + private fun hasSmsExportMessage(threadId: Long): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$THREAD_ID = ? AND $TYPE = ?", threadId, MessageTypes.SMS_EXPORT_TYPE) + .run() + } + + private fun getMessageCountForRecipientsAndType(typeClause: String): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$typeClause AND $DATE_SENT > ?", (System.currentTimeMillis() - InsightsConstants.PERIOD_IN_MILLIS)) + .run() + .readToSingleInt() + } + + private val outgoingInsecureMessageClause = "($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE} AND NOT ($TYPE & ${MessageTypes.SECURE_MESSAGE_BIT})" + private val outgoingSecureMessageClause = "($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE} AND ($TYPE & ${MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT})" + + private val secureMessageClause: String + get() { + val isSent = "($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE}" + val isReceived = "($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_INBOX_TYPE}" + val isSecure = "($TYPE & ${MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT})" + return "($isSent OR $isReceived) AND $isSecure" + } + + private fun getInsecureMessageClause(): String { + return getInsecureMessageClause(-1) + } + + private fun getInsecureMessageClause(threadId: Long): String { + val isSent = "($TABLE_NAME.$TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE}" + val isReceived = "($TABLE_NAME.$TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_INBOX_TYPE}" + val isSecure = "($TABLE_NAME.$TYPE & ${MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT})" + val isNotSecure = "($TABLE_NAME.$TYPE <= ${MessageTypes.BASE_TYPE_MASK or MessageTypes.MESSAGE_ATTRIBUTE_MASK})" + + var whereClause = "($isSent OR $isReceived) AND NOT $isSecure AND $isNotSecure" + + if (threadId != -1L) { + whereClause += " AND $TABLE_NAME.$THREAD_ID = $threadId" + } + + return whereClause + } + + fun getUnexportedInsecureMessagesCount(): Int { + return getUnexportedInsecureMessagesCount(-1) + } + + fun getUnexportedInsecureMessagesCount(threadId: Long): Int { + return writableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("${getInsecureMessageClause(threadId)} AND $EXPORTED < ?", MessageExportStatus.EXPORTED) + .run() + .readToSingleInt() + } + + /** + * Resets the exported state and exported flag so messages can be re-exported. + */ + fun clearExportState() { + writableDatabase.update(TABLE_NAME) + .values( + EXPORT_STATE to null, + EXPORTED to MessageExportStatus.UNEXPORTED.serialize() + ) + .where("$EXPORT_STATE IS NOT NULL OR $EXPORTED != ?", MessageExportStatus.UNEXPORTED) + .run() + } + + /** + * Reset the exported status (not state) to the default for clearing errors. + */ + fun clearInsecureMessageExportedErrorStatus() { + writableDatabase.update(TABLE_NAME) + .values(EXPORTED to MessageExportStatus.UNEXPORTED.serialize()) + .where("$EXPORTED < ?", MessageExportStatus.UNEXPORTED) + .run() + } + + fun setReactionsSeen(threadId: Long, sinceTimestamp: Long) { + val where = "$THREAD_ID = ? AND $REACTIONS_UNREAD = ?" + if (sinceTimestamp > -1) " AND $DATE_RECEIVED <= $sinceTimestamp" else "" + + writableDatabase + .update(TABLE_NAME) + .values( + REACTIONS_UNREAD to 0, + REACTIONS_LAST_SEEN to System.currentTimeMillis() + ) + .where(where, threadId, 1) + .run() + } + + fun setAllReactionsSeen() { + writableDatabase + .update(TABLE_NAME) + .values( + REACTIONS_UNREAD to 0, + REACTIONS_LAST_SEEN to System.currentTimeMillis() + ) + .where("$REACTIONS_UNREAD != ?", 0) + .run() + } + + fun setNotifiedTimestamp(timestamp: Long, ids: List) { + if (ids.isEmpty()) { + return + } + + val query = buildSingleCollectionQuery(ID, ids) + + writableDatabase + .update(TABLE_NAME) + .values(NOTIFIED_TIMESTAMP to timestamp) + .where(query.where, query.whereArgs) + .run() + } + + fun addMismatchedIdentity(messageId: Long, recipientId: RecipientId, identityKey: IdentityKey?) { + try { + addToDocument( + messageId = messageId, + column = MISMATCHED_IDENTITIES, + item = IdentityKeyMismatch(recipientId, identityKey), + clazz = IdentityKeyMismatchSet::class.java + ) + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + fun removeMismatchedIdentity(messageId: Long, recipientId: RecipientId, identityKey: IdentityKey?) { + try { + removeFromDocument( + messageId = messageId, + column = MISMATCHED_IDENTITIES, + item = IdentityKeyMismatch(recipientId, identityKey), + clazz = IdentityKeyMismatchSet::class.java + ) + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + fun setMismatchedIdentities(messageId: Long, mismatches: Set) { + try { + setDocument( + database = databaseHelper.signalWritableDatabase, + messageId = messageId, + column = MISMATCHED_IDENTITIES, + document = IdentityKeyMismatchSet(mismatches) + ) + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + private fun getReportSpamMessageServerGuids(threadId: Long, timestamp: Long): List { + val data: MutableList = ArrayList() + + readableDatabase + .select(RECIPIENT_ID, SERVER_GUID, DATE_RECEIVED) + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $DATE_RECEIVED <= ?", threadId, timestamp) + .orderBy("$DATE_RECEIVED DESC") + .limit(3) + .run() + .forEach { cursor -> + val serverGuid: String? = cursor.requireString(SERVER_GUID) + + if (serverGuid != null && serverGuid.isNotEmpty()) { + data += ReportSpamData( + recipientId = RecipientId.from(cursor.requireLong(RECIPIENT_ID)), + serverGuid = serverGuid, + dateReceived = cursor.requireLong(DATE_RECEIVED) + ) + } + } + + return data + } + + fun getIncomingPaymentRequestThreads(): List { + return readableDatabase + .select("DISTINCT $THREAD_ID") + .from(TABLE_NAME) + .where("($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_INBOX_TYPE} AND ($TYPE & ?) != 0", MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST) + .run() + .readToList { it.requireLong(THREAD_ID) } + } + + fun getPaymentMessage(paymentUuid: UUID): MessageId? { + val id = readableDatabase + .select(ID) + .from(TABLE_NAME) + .where("$TYPE & ? != 0 AND body = ?", MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION, paymentUuid) + .run() + .readToSingleLong(-1) + + return if (id != -1L) { + MessageId(id) + } else { + null + } + } + + /** + * @return The user that added you to the group, otherwise null. + */ + fun getGroupAddedBy(threadId: Long): RecipientId? { + var lastQuitChecked = System.currentTimeMillis() + var pair: Pair + + do { + pair = getGroupAddedBy(threadId, lastQuitChecked) + + if (pair.first() != null) { + return pair.first() + } else { + lastQuitChecked = pair.second() + } + } while (pair.second() != -1L) + + return null + } + + private fun getGroupAddedBy(threadId: Long, lastQuitChecked: Long): Pair { + val latestQuit = messages.getLatestGroupQuitTimestamp(threadId, lastQuitChecked) + val id = messages.getOldestGroupUpdateSender(threadId, latestQuit) + return Pair(id, latestQuit) + } + + /** + * Whether or not the message has been quoted by another message. + */ + fun isQuoted(messageRecord: MessageRecord): Boolean { + val author = if (messageRecord.isOutgoing) Recipient.self().id else messageRecord.recipient.id + + return readableDatabase + .exists(TABLE_NAME) + .where("$QUOTE_ID = ? AND $QUOTE_AUTHOR = ? AND $SCHEDULED_DATE = ?", messageRecord.dateSent, author, -1) + .run() + } + + /** + * Given a collection of MessageRecords, this will return a set of the IDs of the records that have been quoted by another message. + * Does an efficient bulk lookup that makes it faster than [.isQuoted] for multiple records. + */ + fun isQuoted(records: Collection): Set { + if (records.isEmpty()) { + return emptySet() + } + + val byQuoteDescriptor: MutableMap = HashMap(records.size) + val args: MutableList> = ArrayList(records.size) + + for (record in records) { + val timestamp = record.dateSent + val author = if (record.isOutgoing) Recipient.self().id else record.recipient.id + + byQuoteDescriptor[QuoteDescriptor(timestamp, author)] = record + args.add(buildArgs(timestamp, author, -1)) + } + + val quotedIds: MutableSet = mutableSetOf() + + buildCustomCollectionQuery("$QUOTE_ID = ? AND $QUOTE_AUTHOR = ? AND $SCHEDULED_DATE = ?", args).forEach { query -> + readableDatabase + .select(QUOTE_ID, QUOTE_AUTHOR) + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .forEach { cursor -> + val quoteLocator = QuoteDescriptor( + timestamp = cursor.requireLong(QUOTE_ID), + author = RecipientId.from(cursor.requireNonNullString(QUOTE_AUTHOR)) + ) + + quotedIds += byQuoteDescriptor[quoteLocator]!!.id + } + } + + return quotedIds + } + + fun getRootOfQuoteChain(id: MessageId): MessageId { + val targetMessage: MmsMessageRecord = messages.getMessageRecord(id.id) as MmsMessageRecord + + if (targetMessage.quote == null) { + return id + } + + val query: String = if (targetMessage.quote!!.author == Recipient.self().id) { + "$DATE_SENT = ${targetMessage.quote!!.id} AND ($TYPE & ${MessageTypes.BASE_TYPE_MASK}) = ${MessageTypes.BASE_SENT_TYPE}" + } else { + "$DATE_SENT = ${targetMessage.quote!!.id} AND $RECIPIENT_ID = '${targetMessage.quote!!.author.serialize()}'" + } + + MmsReader(readableDatabase.query(TABLE_NAME, MMS_PROJECTION, query, null, null, null, "1")).use { reader -> + val record: MessageRecord? = reader.getNext() + if (record != null) { + return getRootOfQuoteChain(MessageId(record.id)) + } + } + + return id + } + + fun getAllMessagesThatQuote(id: MessageId): List { + val targetMessage: MessageRecord = getMessageRecord(id.id) + val author = if (targetMessage.isOutgoing) Recipient.self().id else targetMessage.recipient.id + + val query = "$QUOTE_ID = ${targetMessage.dateSent} AND $QUOTE_AUTHOR = ${author.serialize()} AND $SCHEDULED_DATE = -1" + val order = "$DATE_RECEIVED DESC" + + val records: MutableList = ArrayList() + MmsReader(readableDatabase.query(TABLE_NAME, MMS_PROJECTION, query, null, null, null, order)).use { reader -> + for (record in reader) { + records += record + records += getAllMessagesThatQuote(MessageId(record.id)) + } + } + + return records.sortedByDescending { it.dateReceived } + } + + fun getQuotedMessagePosition(threadId: Long, quoteId: Long, recipientId: RecipientId): Int { + val isOwnNumber = Recipient.resolved(recipientId).isSelf + + readableDatabase + .select(DATE_SENT, RECIPIENT_ID, REMOTE_DELETED) + .from(TABLE_NAME) + .where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1") + .orderBy("$DATE_RECEIVED DESC") + .run() + .forEach { cursor -> + val quoteIdMatches = cursor.requireLong(DATE_SENT) == quoteId + val recipientIdMatches = recipientId == RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + + if (quoteIdMatches && (recipientIdMatches || isOwnNumber)) { + return if (cursor.requireBoolean(REMOTE_DELETED)) { + -1 + } else { + cursor.position + } + } + } + + return -1 + } + + fun getMessagePositionInConversation(threadId: Long, receivedTimestamp: Long, recipientId: RecipientId): Int { + val isOwnNumber = Recipient.resolved(recipientId).isSelf + + readableDatabase + .select(DATE_RECEIVED, RECIPIENT_ID, REMOTE_DELETED) + .from(TABLE_NAME) + .where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1") + .orderBy("$DATE_RECEIVED DESC") + .run() + .forEach { cursor -> + val timestampMatches = cursor.requireLong(DATE_RECEIVED) == receivedTimestamp + val recipientIdMatches = recipientId == RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + + if (timestampMatches && (recipientIdMatches || isOwnNumber)) { + return if (cursor.requireBoolean(REMOTE_DELETED)) { + -1 + } else { + cursor.position + } + } + } + + return -1 + } + + fun getMessagePositionInConversation(threadId: Long, receivedTimestamp: Long): Int { + return getMessagePositionInConversation(threadId, 0, receivedTimestamp) + } + + /** + * Retrieves the position of the message with the provided timestamp in the query results you'd + * get from calling [.getConversation]. + * + * Note: This could give back incorrect results in the situation where multiple messages have the + * same received timestamp. However, because this was designed to determine where to scroll to, + * you'll still wind up in about the right spot. + * + * @param groupStoryId Ignored if passed value is <= 0 + */ + fun getMessagePositionInConversation(threadId: Long, groupStoryId: Long, receivedTimestamp: Long): Int { + val order: String + val selection: String + + if (groupStoryId > 0) { + order = "$DATE_RECEIVED ASC" + selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1" + } else { + order = "$DATE_RECEIVED DESC" + selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1" + } + + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where(selection) + .orderBy(order) + .run() + .readToSingleInt(-1) + } + + fun getTimestampForFirstMessageAfterDate(date: Long): Long { + return readableDatabase + .select(DATE_RECEIVED) + .from(TABLE_NAME) + .where("$DATE_RECEIVED > $date AND $SCHEDULED_DATE = -1") + .orderBy("$DATE_RECEIVED ASC") + .limit(1) + .run() + .readToSingleLong() + } + + fun getMessageCountBeforeDate(date: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$DATE_RECEIVED < $date AND $SCHEDULED_DATE = -1") + .run() + .readToSingleInt() + } + + @Throws(NoSuchMessageException::class) + fun getMessagesAfterVoiceNoteInclusive(messageId: Long, limit: Long): List { + val origin: MessageRecord = getMessageRecord(messageId) + + return getMessagesInThreadAfterInclusive(origin.threadId, origin.dateReceived, limit) + .sortedBy { it.dateReceived } + .take(limit.toInt()) + } + + fun getMessagePositionOnOrAfterTimestamp(threadId: Long, timestamp: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from(TABLE_NAME) + .where("$THREAD_ID = $threadId AND $DATE_RECEIVED >= $timestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1") + .run() + .readToSingleInt() + } + + @Throws(NoSuchMessageException::class) + fun getConversationSnippetType(threadId: Long): Long { + return readableDatabase + .rawQuery(SNIPPET_QUERY, buildArgs(threadId)) + .readToSingleObject { it.requireLong(TYPE) } ?: throw NoSuchMessageException("no message") + } + + @Throws(NoSuchMessageException::class) + fun getConversationSnippet(threadId: Long): MessageRecord { + return getConversationSnippetCursor(threadId) + .readToSingleObject { cursor -> + val id = cursor.requireLong(ID) + messages.getMessageRecord(id) + } ?: throw NoSuchMessageException("no message") + } + + @VisibleForTesting + fun getConversationSnippetCursor(threadId: Long): Cursor { + val db = databaseHelper.signalReadableDatabase + return db.rawQuery(SNIPPET_QUERY, buildArgs(threadId)) + } + + fun getUnreadCount(threadId: Long): Int { + return readableDatabase + .select("COUNT(*)") + .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_DATE") + .where("$READ = 0 AND $STORY_TYPE = 0 AND $THREAD_ID = $threadId AND $PARENT_STORY_ID <= 0") + .run() + .readToSingleInt() + } + + fun checkMessageExists(messageRecord: MessageRecord): Boolean { + return readableDatabase + .exists(TABLE_NAME) + .where("$ID = ?", messageRecord.id) + .run() + } + + fun getReportSpamMessageServerData(threadId: Long, timestamp: Long, limit: Int): List { + return getReportSpamMessageServerGuids(threadId, timestamp) + .sortedBy { it.dateReceived } + .take(limit) + } + + @Throws(NoSuchMessageException::class) + private fun getMessageExportState(messageId: MessageId): MessageExportState { + return readableDatabase + .select(EXPORT_STATE) + .from(TABLE_NAME) + .where("$ID = ?", messageId.id) + .run() + .readToSingleObject { cursor -> + val bytes: ByteArray? = cursor.requireBlob(EXPORT_STATE) + + if (bytes == null) { + MessageExportState.getDefaultInstance() + } else { + try { + MessageExportState.parseFrom(bytes) + } catch (e: InvalidProtocolBufferException) { + MessageExportState.getDefaultInstance() + } + } + } ?: throw NoSuchMessageException("The requested message does not exist.") + } + + @Throws(NoSuchMessageException::class) + fun updateMessageExportState(messageId: MessageId, transform: Function) { + writableDatabase.withinTransaction { db -> + val oldState = getMessageExportState(messageId) + val newState = transform.apply(oldState) + setMessageExportState(messageId, newState) + } + } + + fun markMessageExported(messageId: MessageId) { + writableDatabase + .update(TABLE_NAME) + .values(EXPORTED to MessageExportStatus.EXPORTED.serialize()) + .where("$ID = ?", messageId.id) + .run() + } + + fun markMessageExportFailed(messageId: MessageId) { + writableDatabase + .update(TABLE_NAME) + .values(EXPORTED to MessageExportStatus.ERROR.serialize()) + .where("$ID = ?", messageId.id) + .run() + } + + private fun setMessageExportState(messageId: MessageId, messageExportState: MessageExportState) { + writableDatabase + .update(TABLE_NAME) + .values(EXPORT_STATE to messageExportState.toByteArray()) + .where("$ID = ?", messageId.id) + .run() + } + + fun incrementDeliveryReceiptCounts(syncMessageIds: List, timestamp: Long): Collection { + return incrementReceiptCounts(syncMessageIds, timestamp, ReceiptType.DELIVERY) + } + + fun incrementDeliveryReceiptCount(syncMessageId: SyncMessageId, timestamp: Long): Boolean { + return incrementReceiptCount(syncMessageId, timestamp, ReceiptType.DELIVERY) + } + + /** + * @return A list of ID's that were not updated. + */ + fun incrementReadReceiptCounts(syncMessageIds: List, timestamp: Long): Collection { + return incrementReceiptCounts(syncMessageIds, timestamp, ReceiptType.READ) + } + + fun incrementReadReceiptCount(syncMessageId: SyncMessageId, timestamp: Long): Boolean { + return incrementReceiptCount(syncMessageId, timestamp, ReceiptType.READ) + } + + /** + * @return A list of ID's that were not updated. + */ + fun incrementViewedReceiptCounts(syncMessageIds: List, timestamp: Long): Collection { + return incrementReceiptCounts(syncMessageIds, timestamp, ReceiptType.VIEWED) + } + + fun incrementViewedNonStoryReceiptCounts(syncMessageIds: List, timestamp: Long): Collection { + return incrementReceiptCounts(syncMessageIds, timestamp, ReceiptType.VIEWED, MessageQualifier.NORMAL) + } + + fun incrementViewedReceiptCount(syncMessageId: SyncMessageId, timestamp: Long): Boolean { + return incrementReceiptCount(syncMessageId, timestamp, ReceiptType.VIEWED) + } + + fun incrementViewedStoryReceiptCounts(syncMessageIds: List, timestamp: Long): Collection { + val messageUpdates: MutableSet = HashSet() + val unhandled: MutableSet = HashSet() + + writableDatabase.withinTransaction { + for (id in syncMessageIds) { + val updates = incrementReceiptCountInternal(id, timestamp, ReceiptType.VIEWED, MessageQualifier.STORY) + + if (updates.isNotEmpty()) { + messageUpdates += updates + } else { + unhandled += id + } + } + } + + for (update in messageUpdates) { + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(update.messageId) + ApplicationDependencies.getDatabaseObserver().notifyVerboseConversationListeners(setOf(update.threadId)) + } + + if (messageUpdates.isNotEmpty()) { + notifyConversationListListeners() + } + + return unhandled + } + + /** + * Wraps a single receipt update in a transaction and triggers the proper updates. + * + * @return Whether or not some thread was updated. + */ + private fun incrementReceiptCount(syncMessageId: SyncMessageId, timestamp: Long, receiptType: ReceiptType, messageQualifier: MessageQualifier = MessageQualifier.ALL): Boolean { + var messageUpdates: Set = HashSet() + + writableDatabase.withinTransaction { + messageUpdates = incrementReceiptCountInternal(syncMessageId, timestamp, receiptType, messageQualifier) + + for (messageUpdate in messageUpdates) { + threads.update(messageUpdate.threadId, false) + } + } + + for (threadUpdate in messageUpdates) { + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(threadUpdate.messageId) + } + + return messageUpdates.isNotEmpty() + } + + /** + * Wraps multiple receipt updates in a transaction and triggers the proper updates. + * + * @return All of the messages that didn't result in updates. + */ + private fun incrementReceiptCounts(syncMessageIds: List, timestamp: Long, receiptType: ReceiptType, messageQualifier: MessageQualifier = MessageQualifier.ALL): Collection { + val messageUpdates: MutableSet = HashSet() + val unhandled: MutableSet = HashSet() + + writableDatabase.withinTransaction { + for (id in syncMessageIds) { + val updates = incrementReceiptCountInternal(id, timestamp, receiptType, messageQualifier) + + if (updates.isNotEmpty()) { + messageUpdates += updates + } else { + unhandled += id + } + } + + for (update in messageUpdates) { + threads.updateSilently(update.threadId, false) + } + } + + for (update in messageUpdates) { + ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(update.messageId) + ApplicationDependencies.getDatabaseObserver().notifyVerboseConversationListeners(setOf(update.threadId)) + + if (messageQualifier == MessageQualifier.STORY) { + ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(threads.getRecipientIdForThreadId(update.threadId)!!) + } + } + + if (messageUpdates.isNotEmpty()) { + notifyConversationListListeners() + } + + return unhandled + } + + private fun incrementReceiptCountInternal(messageId: SyncMessageId, timestamp: Long, receiptType: ReceiptType, messageQualifier: MessageQualifier): Set { + val messageUpdates: MutableSet = HashSet() + + val qualifierWhere: String = when (messageQualifier) { + MessageQualifier.NORMAL -> " AND NOT ($IS_STORY_CLAUSE)" + MessageQualifier.STORY -> " AND $IS_STORY_CLAUSE" + MessageQualifier.ALL -> "" + } + + readableDatabase + .select(ID, THREAD_ID, TYPE, RECIPIENT_ID, receiptType.columnName, RECEIPT_TIMESTAMP) + .from(TABLE_NAME) + .where("$DATE_SENT = ? $qualifierWhere", messageId.timetamp) + .run() + .forEach { cursor -> + if (MessageTypes.isOutgoingMessageType(cursor.requireLong(TYPE))) { + val theirRecipientId = RecipientId.from(cursor.requireLong(RECIPIENT_ID)) + val ourRecipientId = messageId.recipientId + val columnName = receiptType.columnName + + if (ourRecipientId == theirRecipientId || Recipient.resolved(theirRecipientId).isGroup) { + val id = cursor.requireLong(ID) + val threadId = cursor.requireLong(THREAD_ID) + val status = receiptType.groupStatus + val isFirstIncrement = cursor.requireLong(columnName) == 0L + val savedTimestamp = cursor.requireLong(RECEIPT_TIMESTAMP) + val updatedTimestamp = if (isFirstIncrement) max(savedTimestamp, timestamp) else savedTimestamp + + writableDatabase.execSQL( + """ + UPDATE $TABLE_NAME + SET + $columnName = $columnName + 1, + $RECEIPT_TIMESTAMP = ? + WHERE $ID = ? + """.toSingleLine(), + buildArgs(updatedTimestamp, id) + ) + + groupReceipts.update(ourRecipientId, id, status, timestamp) + + messageUpdates += MessageUpdate(threadId, MessageId(id)) + } + } + + if (messageUpdates.isNotEmpty() && receiptType == ReceiptType.DELIVERY) { + earlyDeliveryReceiptCache.increment(messageId.timetamp, messageId.recipientId, timestamp) + } + } + + messageUpdates += incrementStoryReceiptCount(messageId, timestamp, receiptType) + + return messageUpdates + } + + private fun incrementStoryReceiptCount(messageId: SyncMessageId, timestamp: Long, receiptType: ReceiptType): Set { + val messageUpdates: MutableSet = HashSet() + val columnName = receiptType.columnName + + for (storyMessageId in storySends.getStoryMessagesFor(messageId)) { + writableDatabase.execSQL( + """ + UPDATE $TABLE_NAME + SET + $columnName = $columnName + 1, + $RECEIPT_TIMESTAMP = CASE + WHEN $columnName = 0 THEN MAX($RECEIPT_TIMESTAMP, ?) + ELSE $RECEIPT_TIMESTAMP + END + WHERE $ID = ? + """.toSingleLine(), + buildArgs(timestamp, storyMessageId.id) + ) + + groupReceipts.update(messageId.recipientId, storyMessageId.id, receiptType.groupStatus, timestamp) + + messageUpdates += MessageUpdate(-1, storyMessageId) + } + + return messageUpdates + } + + /** + * @return Unhandled ids + */ + fun setTimestampReadFromSyncMessage(readMessages: List, proposedExpireStarted: Long, threadToLatestRead: MutableMap): Collection { + val expiringMessages: MutableList> = mutableListOf() + val updatedThreads: MutableSet = mutableSetOf() + val unhandled: MutableCollection = mutableListOf() + + writableDatabase.withinTransaction { + for (readMessage in readMessages) { + val authorId: RecipientId = recipients.getOrInsertFromServiceId(readMessage.sender) + + val result: TimestampReadResult = setTimestampReadFromSyncMessageInternal( + messageId = SyncMessageId(authorId, readMessage.timestamp), + proposedExpireStarted = proposedExpireStarted, + threadToLatestRead = threadToLatestRead + ) + + expiringMessages += result.expiring + updatedThreads += result.threads + + if (result.threads.isEmpty()) { + unhandled += SyncMessageId(authorId, readMessage.timestamp) + } + } + + for (threadId in updatedThreads) { + threads.updateReadState(threadId) + threads.setLastSeen(threadId) + } + } + + for (expiringMessage in expiringMessages) { + ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(expiringMessage.first(), true, proposedExpireStarted, expiringMessage.second()) + } + + for (threadId in updatedThreads) { + notifyConversationListeners(threadId) + } + + return unhandled + } + + /** + * Handles a synchronized read message. + * @param messageId An id representing the author-timestamp pair of the message that was read on a linked device. Note that the author could be self when + * syncing read receipts for reactions. + */ + private fun setTimestampReadFromSyncMessageInternal(messageId: SyncMessageId, proposedExpireStarted: Long, threadToLatestRead: MutableMap): TimestampReadResult { + val expiring: MutableList> = LinkedList() + val projection = arrayOf(ID, THREAD_ID, EXPIRES_IN, EXPIRE_STARTED) + val query = "$DATE_SENT = ? AND ($RECIPIENT_ID = ? OR ($RECIPIENT_ID = ? AND ${getOutgoingTypeClause()}))" + val args = buildArgs(messageId.timetamp, messageId.recipientId, Recipient.self().id) + val threads: MutableList = LinkedList() + + readableDatabase + .select(ID, THREAD_ID, EXPIRES_IN, EXPIRE_STARTED) + .from(TABLE_NAME) + .where("$DATE_SENT = ? AND ($RECIPIENT_ID = ? OR ($RECIPIENT_ID = ? AND ${getOutgoingTypeClause()}))", messageId.timetamp, messageId.recipientId, Recipient.self().id) + .run() + .forEach { cursor -> + val id = cursor.requireLong(ID) + val threadId = cursor.requireLong(THREAD_ID) + val expiresIn = cursor.requireLong(EXPIRES_IN) + val expireStarted = cursor.requireLong(EXPIRE_STARTED).let { + if (it > 0) { + min(proposedExpireStarted, it) + } else { + proposedExpireStarted + } + } + + val values = contentValuesOf( + READ to 1, + REACTIONS_UNREAD to 0, + REACTIONS_LAST_SEEN to System.currentTimeMillis() + ) + + if (expiresIn > 0) { + values.put(EXPIRE_STARTED, expireStarted) + expiring += Pair(id, expiresIn) + } + + writableDatabase + .update(TABLE_NAME) + .values(values) + .where("$ID = ?", id) + .run() + + threads += threadId + + val latest: Long? = threadToLatestRead[threadId] + + threadToLatestRead[threadId] = if (latest != null) { + max(latest, messageId.timetamp) + } else { + messageId.timetamp + } + } + + return TimestampReadResult(expiring, threads) + } + + /** + * Finds a message by timestamp+author. + * Does *not* include attachments. + */ + fun getMessageFor(timestamp: Long, authorId: RecipientId): MessageRecord? { + val isSelf = authorId == Recipient.self().id + + val cursor = readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$DATE_SENT = ?", timestamp) + .run() + + mmsReaderFor(cursor).use { reader -> + for (record in reader) { + if ((isSelf && record.isOutgoing) || (!isSelf && record.individualRecipient.id == authorId)) { + return record + } + } + } + + return null + } + + /** + * A cursor containing all of the messages in a given thread, in the proper order. + * This does *not* have attachments in it. + */ + fun getConversation(threadId: Long): Cursor { + return getConversation(threadId, 0, 0) + } + + /** + * A cursor containing all of the messages in a given thread, in the proper order, respecting offset/limit. + * This does *not* have attachments in it. + */ + fun getConversation(threadId: Long, offset: Long, limit: Long): Cursor { + val limitStr: String = if (limit > 0 || offset > 0) "$offset, $limit" else "" + + return readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, 0, 0, -1) + .orderBy("$DATE_RECEIVED DESC") + .limit(limitStr) + .run() + } + + /** + * Returns messages ordered for display in a reverse list (newest first). + */ + fun getScheduledMessagesInThread(threadId: Long): List { + val cursor = readableDatabase + .select(*MMS_PROJECTION) + .from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE") + .where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ?", threadId, 0, 0, -1) + .orderBy("$SCHEDULED_DATE DESC, $ID DESC") + .run() + + return mmsReaderFor(cursor).use { reader -> + reader.filterNotNull() + } + } + + /** + * Returns messages order for sending (oldest first). + */ + fun getScheduledMessagesBefore(time: Long): List { + val cursor = readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ? AND $SCHEDULED_DATE <= ?", 0, 0, -1, time) + .orderBy("$SCHEDULED_DATE ASC, $ID ASC") + .run() + + return mmsReaderFor(cursor).use { reader -> + reader.filterNotNull() + } + } + + fun getOldestScheduledSendTimestamp(): MessageRecord? { + val cursor = readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ?", 0, 0, -1) + .orderBy("$SCHEDULED_DATE ASC, $ID ASC") + .limit(1) + .run() + + return mmsReaderFor(cursor).use { reader -> + reader.firstOrNull() + } + } + + fun getMessagesForNotificationState(stickyThreads: Collection): Cursor { + val stickyQuery = StringBuilder() + + for ((conversationId, _, earliestTimestamp) in stickyThreads) { + if (stickyQuery.isNotEmpty()) { + stickyQuery.append(" OR ") + } + + stickyQuery.append("(") + .append("$THREAD_ID = ") + .append(conversationId.threadId) + .append(" AND ") + .append(DATE_RECEIVED) + .append(" >= ") + .append(earliestTimestamp) + .append(getStickyWherePartForParentStoryId(conversationId.groupStoryId)) + .append(")") + } + + return readableDatabase + .select(*MMS_PROJECTION) + .from(TABLE_NAME) + .where("$NOTIFIED = 0 AND $STORY_TYPE = 0 AND ($READ = 0 OR $REACTIONS_UNREAD = 1 ${if (stickyQuery.isNotEmpty()) "OR ($stickyQuery)" else ""})") + .orderBy("$DATE_RECEIVED ASC") + .run() + } + + private fun getStickyWherePartForParentStoryId(parentStoryId: Long?): String { + return if (parentStoryId == null) { + " AND $PARENT_STORY_ID <= 0" + } else { + " AND $PARENT_STORY_ID = $parentStoryId" + } + } + + override fun remapRecipient(fromId: RecipientId, toId: RecipientId) { + writableDatabase + .update(TABLE_NAME) + .values(RECIPIENT_ID to toId.serialize()) + .where("$RECIPIENT_ID = ?", fromId) + .run() + } + + override fun remapThread(fromId: Long, toId: Long) { + writableDatabase + .update(TABLE_NAME) + .values(THREAD_ID to toId) + .where("$THREAD_ID = ?", fromId) + .run() + } + + /** + * Returns the next ID that would be generated if an insert was done on this table. + * You should *not* use this for actually generating an ID to use. That will happen automatically! + * This was added for a very narrow usecase, and you probably don't need to use it. + */ + fun getNextId(): Long { + return getNextAutoIncrementId(writableDatabase, TABLE_NAME) + } + + fun updateReactionsUnread(db: SQLiteDatabase, messageId: Long, hasReactions: Boolean, isRemoval: Boolean) { + try { + val isOutgoing = getMessageRecord(messageId).isOutgoing + val values = ContentValues() + + if (!hasReactions) { + values.put(REACTIONS_UNREAD, 0) + } else if (!isRemoval) { + values.put(REACTIONS_UNREAD, 1) + } + + if (isOutgoing && hasReactions) { + values.put(NOTIFIED, 0) + } + + if (values.size() > 0) { + db.update(TABLE_NAME) + .values(values) + .where("$ID = ?", messageId) + .run() + } + } catch (e: NoSuchMessageException) { + Log.w(TAG, "Failed to find message $messageId") + } + } + + @Throws(IOException::class) + protected fun ?, I> removeFromDocument(messageId: Long, column: String, item: I, clazz: Class) { + writableDatabase.withinTransaction { db -> + val document: D = getDocument(db, messageId, column, clazz) + val iterator = document!!.items.iterator() + + while (iterator.hasNext()) { + val found = iterator.next() + if (found == item) { + iterator.remove() + break + } + } + + setDocument(db, messageId, column, document) + } + } + + @Throws(IOException::class) + protected fun ?, I> addToDocument(messageId: Long, column: String, item: I, clazz: Class) { + addToDocument(messageId, column, listOf(item), clazz) + } + + @Throws(IOException::class) + protected fun ?, I> addToDocument(messageId: Long, column: String, objects: List?, clazz: Class) { + writableDatabase.withinTransaction { db -> + val document: T = getDocument(db, messageId, column, clazz) + document!!.items.addAll(objects!!) + setDocument(db, messageId, column, document) + } + } + + @Throws(IOException::class) + protected fun setDocument(database: SQLiteDatabase, messageId: Long, column: String?, document: Document<*>?) { + val contentValues = ContentValues() + + if (document == null || document.size() == 0) { + contentValues.put(column, null as String?) + } else { + contentValues.put(column, JsonUtils.toJson(document)) + } + + database + .update(TABLE_NAME) + .values(contentValues) + .where("$ID = ?", messageId) + .run() + } + + private fun ?> getDocument( + database: SQLiteDatabase, + messageId: Long, + column: String, + clazz: Class + ): D { + return database + .select(column) + .from(TABLE_NAME) + .where("$ID = ?", messageId) + .run() + .readToSingleObject { cursor -> + val document: String? = cursor.requireString(column) + + if (!document.isNullOrEmpty()) { + try { + JsonUtils.fromJson(document, clazz) + } catch (e: IOException) { + Log.w(TAG, e) + clazz.newInstance() + } + } else { + clazz.newInstance() + } + }!! + } + + fun getBodyRangesForMessages(messageIds: List): Map { + val bodyRanges: MutableMap = HashMap() + + SqlUtil.buildCollectionQuery(ID, messageIds).forEach { query -> + readableDatabase + .select(ID, MESSAGE_RANGES) + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .forEach { cursor -> + val data: ByteArray? = cursor.requireBlob(MESSAGE_RANGES) + + if (data != null) { + try { + bodyRanges[CursorUtil.requireLong(cursor, ID)] = BodyRangeList.parseFrom(data) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, "Unable to parse body ranges for search", e) + } + } + } + } + + return bodyRanges + } + + private fun generatePduCompatTimestamp(time: Long): Long { + return time - time % 1000 + } + + private fun getReleaseChannelThreadId(hasSeenReleaseChannelStories: Boolean): Long { + if (hasSeenReleaseChannelStories) { + return -1L + } + + val releaseChannelRecipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId ?: return -1L + return threads.getThreadIdFor(releaseChannelRecipientId) ?: return -1L + } + + private fun Cursor.toMarkedMessageInfo(): MarkedMessageInfo { + return MarkedMessageInfo( + messageId = MessageId(this.requireLong(ID)), + threadId = this.requireLong(THREAD_ID), + syncMessageId = SyncMessageId( + recipientId = RecipientId.from(this.requireLong(RECIPIENT_ID)), + timetamp = this.requireLong(DATE_SENT) + ), + expirationInfo = null, + storyType = StoryType.fromCode(this.requireInt(STORY_TYPE)) + ) + } + + private fun ByteArray?.toIsoString(): String? { + return if (this != null) { + Util.toIsoString(this) + } else { + null + } + } + + protected enum class ReceiptType(val columnName: String, val groupStatus: Int) { + READ(READ_RECEIPT_COUNT, GroupReceiptTable.STATUS_READ), + DELIVERY(DELIVERY_RECEIPT_COUNT, GroupReceiptTable.STATUS_DELIVERED), + VIEWED(VIEWED_RECEIPT_COUNT, GroupReceiptTable.STATUS_VIEWED); + } + + data class SyncMessageId( + val recipientId: RecipientId, + val timetamp: Long + ) + + data class ExpirationInfo( + val id: Long, + val expiresIn: Long, + val expireStarted: Long, + val isMms: Boolean + ) + + data class MarkedMessageInfo( + val threadId: Long, + val syncMessageId: SyncMessageId, + val messageId: MessageId, + val expirationInfo: ExpirationInfo?, + val storyType: StoryType + ) + + data class InsertResult( + val messageId: Long, + val threadId: Long + ) + + data class MmsNotificationInfo( + val from: RecipientId, + val contentLocation: String, + val transactionId: String, + val subscriptionId: Int + ) + + data class MessageUpdate( + val threadId: Long, + val messageId: MessageId + ) + + data class ReportSpamData( + val recipientId: RecipientId, + val serverGuid: String, + val dateReceived: Long + ) + + private data class QuoteDescriptor( + private val timestamp: Long, + private val author: RecipientId + ) + + private class TimestampReadResult( + val expiring: List>, + val threads: List + ) + + /** + * Describes which messages to act on. This is used when incrementing receipts. + * Specifically, this was added to support stories having separate viewed receipt settings. + */ + enum class MessageQualifier { + /** A normal database message (i.e. not a story) */ + NORMAL, + + /** A story message */ + STORY, + + /** Both normal and story message */ + ALL + } + + object MmsStatus { + const val DOWNLOAD_INITIALIZED = 1 + const val DOWNLOAD_NO_CONNECTIVITY = 2 + const val DOWNLOAD_CONNECTING = 3 + const val DOWNLOAD_SOFT_FAILURE = 4 + const val DOWNLOAD_HARD_FAILURE = 5 + const val DOWNLOAD_APN_UNAVAILABLE = 6 + } + + object Status { + const val STATUS_NONE = -1 + const val STATUS_COMPLETE = 0 + const val STATUS_PENDING = 0x20 + const val STATUS_FAILED = 0x40 + } + + fun interface InsertListener { + fun onComplete() + } + + /** + * Allows the developer to safely iterate over and close a cursor containing + * data for MessageRecord objects. Supports for-each loops as well as try-with-resources + * blocks. + * + * Readers are considered "one-shot" and it's on the caller to decide what needs + * to be done with the data. Once read, a reader cannot be read from again. This + * is by design, since reading data out of a cursor involves object creations and + * lookups, so it is in the best interest of app performance to only read out the + * data once. If you need to parse the list multiple times, it is recommended that + * you copy the iterable out into a normal List, or use extension methods such as + * partition. + * + * This reader does not support removal, since this would be considered a destructive + * database call. + */ + interface Reader : Closeable, Iterable { + + @Deprecated("Use the Iterable interface instead.") + fun getNext(): MessageRecord? + + @Deprecated("Use the Iterable interface instead.") + fun getCurrent(): MessageRecord + + /** + * Pulls the export state out of the query, if it is present. + */ + fun getMessageExportStateForCurrentRecord(): MessageExportState + + /** + * From the [Closeable] interface, removing the IOException requirement. + */ + override fun close() + } + + /** + * MessageRecord reader which implements the Iterable interface. This allows it to + * be used with many Kotlin Extension Functions as well as with for-each loops. + * + * Note that it's the responsibility of the developer using the reader to ensure that: + * + * 1. They only utilize one of the two interfaces (legacy or iterator) + * 1. They close this reader after use, preferably via try-with-resources or a use block. + */ + class MmsReader(val cursor: Cursor) : Reader { + private val context: Context + + init { + context = ApplicationDependencies.getApplication() + } + + override fun getNext(): MessageRecord? { + return if (!cursor.moveToNext()) { + null + } else { + getCurrent() + } + } + + override fun getCurrent(): MessageRecord { + val mmsType = cursor.requireLong(MMS_MESSAGE_TYPE) + + return if (mmsType == PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND.toLong()) { + getNotificationMmsMessageRecord(cursor) + } else { + getMediaMmsMessageRecord(cursor) + } + } + + override fun getMessageExportStateForCurrentRecord(): MessageExportState { + val messageExportState = CursorUtil.requireBlob(cursor, EXPORT_STATE) ?: return MessageExportState.getDefaultInstance() + return try { + MessageExportState.parseFrom(messageExportState) + } catch (e: InvalidProtocolBufferException) { + MessageExportState.getDefaultInstance() + } + } + + override fun close() { + cursor.close() + } + + override fun iterator(): Iterator { + return ReaderIterator() + } + + fun getCount(): Int { + return cursor.count + } + + fun getCurrentId(): MessageId { + return MessageId(cursor.requireLong(ID)) + } + + private fun getNotificationMmsMessageRecord(cursor: Cursor): NotificationMmsMessageRecord { + val id = cursor.requireLong(ID) + val dateSent = cursor.requireLong(DATE_SENT) + val dateReceived = cursor.requireLong(DATE_RECEIVED) + val threadId = cursor.requireLong(THREAD_ID) + val mailbox = cursor.requireLong(TYPE) + val recipientId = cursor.requireLong(RECIPIENT_ID) + val addressDeviceId = cursor.requireInt(RECIPIENT_DEVICE_ID) + val recipient = Recipient.live(RecipientId.from(recipientId)).get() + val contentLocation = cursor.requireString(MMS_CONTENT_LOCATION).toIsoBytes() + val transactionId = cursor.requireString(MMS_TRANSACTION_ID).toIsoBytes() + val messageSize = cursor.requireLong(MMS_MESSAGE_SIZE) + val expiry = cursor.requireLong(MMS_EXPIRY) + val status = cursor.requireInt(MMS_STATUS) + val deliveryReceiptCount = cursor.requireInt(DELIVERY_RECEIPT_COUNT) + var readReceiptCount = cursor.requireInt(READ_RECEIPT_COUNT) + val subscriptionId = cursor.requireInt(SMS_SUBSCRIPTION_ID) + val viewedReceiptCount = cursor.requireInt(VIEWED_RECEIPT_COUNT) + val receiptTimestamp = cursor.requireLong(RECEIPT_TIMESTAMP) + val storyType = StoryType.fromCode(cursor.requireInt(STORY_TYPE)) + val parentStoryId = ParentStoryId.deserialize(cursor.requireLong(PARENT_STORY_ID)) + val body = cursor.requireString(BODY) + + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { + readReceiptCount = 0 + } + + val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize)) + val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(mailbox)) { + try { + GiftBadge.parseFrom(Base64.decode(body)) + } catch (e: IOException) { + Log.w(TAG, "Error parsing gift badge", e) + null + } + } else { + null + } + + return NotificationMmsMessageRecord( + id, + recipient, + recipient, + addressDeviceId, + dateSent, + dateReceived, + deliveryReceiptCount, + threadId, + contentLocation, + messageSize, + expiry, + status, + transactionId, + mailbox, + subscriptionId, + slideDeck, + readReceiptCount, + viewedReceiptCount, + receiptTimestamp, + storyType, + parentStoryId, + giftBadge + ) + } + + private fun getMediaMmsMessageRecord(cursor: Cursor): MediaMmsMessageRecord { + val id = cursor.requireLong(ID) + val dateSent = cursor.requireLong(DATE_SENT) + val dateReceived = cursor.requireLong(DATE_RECEIVED) + val dateServer = cursor.requireLong(DATE_SERVER) + val box = cursor.requireLong(TYPE) + val threadId = cursor.requireLong(THREAD_ID) + val recipientId = cursor.requireLong(RECIPIENT_ID) + val addressDeviceId = cursor.requireInt(RECIPIENT_DEVICE_ID) + val deliveryReceiptCount = cursor.requireInt(DELIVERY_RECEIPT_COUNT) + var readReceiptCount = cursor.requireInt(READ_RECEIPT_COUNT) + val body = cursor.requireString(BODY) + val mismatchDocument = cursor.requireString(MISMATCHED_IDENTITIES) + val networkDocument = cursor.requireString(NETWORK_FAILURES) + val subscriptionId = cursor.requireInt(SMS_SUBSCRIPTION_ID) + val expiresIn = cursor.requireLong(EXPIRES_IN) + val expireStarted = cursor.requireLong(EXPIRE_STARTED) + val unidentified = cursor.requireBoolean(UNIDENTIFIED) + val isViewOnce = cursor.requireBoolean(VIEW_ONCE) + val remoteDelete = cursor.requireBoolean(REMOTE_DELETED) + val mentionsSelf = cursor.requireBoolean(MENTIONS_SELF) + val notifiedTimestamp = cursor.requireLong(NOTIFIED_TIMESTAMP) + var viewedReceiptCount = cursor.requireInt(VIEWED_RECEIPT_COUNT) + val receiptTimestamp = cursor.requireLong(RECEIPT_TIMESTAMP) + val messageRangesData = cursor.requireBlob(MESSAGE_RANGES) + val storyType = StoryType.fromCode(cursor.requireInt(STORY_TYPE)) + val parentStoryId = ParentStoryId.deserialize(cursor.requireLong(PARENT_STORY_ID)) + val scheduledDate = cursor.requireLong(SCHEDULED_DATE) + + if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { + readReceiptCount = 0 + if (MessageTypes.isOutgoingMessageType(box) && !storyType.isStory) { + viewedReceiptCount = 0 + } + } + + val recipient = Recipient.live(RecipientId.from(recipientId)).get() + val mismatches = getMismatchedIdentities(mismatchDocument) + val networkFailures = getFailures(networkDocument) + + val attachments = attachments.getAttachments(cursor) + + val contacts = getSharedContacts(cursor, attachments) + val contactAttachments = contacts.mapNotNull { it.avatarAttachment }.toSet() + + val previews = getLinkPreviews(cursor, attachments) + val previewAttachments = previews.mapNotNull { it.thumbnail.orElse(null) }.toSet() + + val slideDeck = buildSlideDeck(context, attachments.filterNot { contactAttachments.contains(it) }.filterNot { previewAttachments.contains(it) }) + + val quote = getQuote(cursor) + + val messageRanges: BodyRangeList? = if (messageRangesData != null) { + try { + BodyRangeList.parseFrom(messageRangesData) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, "Error parsing message ranges", e) + null + } + } else { + null + } + + val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(box)) { + try { + GiftBadge.parseFrom(Base64.decode(body)) + } catch (e: IOException) { + Log.w(TAG, "Error parsing gift badge", e) + null + } + } else { + null + } + + return MediaMmsMessageRecord( + id, + recipient, + recipient, + addressDeviceId, + dateSent, + dateReceived, + dateServer, + deliveryReceiptCount, + threadId, + body, + slideDeck, + box, + mismatches, + networkFailures, + subscriptionId, + expiresIn, + expireStarted, + isViewOnce, + readReceiptCount, + quote, + contacts, + previews, + unidentified, + emptyList(), + remoteDelete, + mentionsSelf, + notifiedTimestamp, + viewedReceiptCount, + receiptTimestamp, + messageRanges, + storyType, + parentStoryId, + giftBadge, + null, + null, + scheduledDate + ) + } + + private fun getMismatchedIdentities(document: String?): Set { + if (!TextUtils.isEmpty(document)) { + try { + return JsonUtils.fromJson(document, IdentityKeyMismatchSet::class.java).items + } catch (e: IOException) { + Log.w(TAG, e) + } + } + + return emptySet() + } + + private fun getFailures(document: String?): Set { + if (!TextUtils.isEmpty(document)) { + try { + return JsonUtils.fromJson(document, NetworkFailureSet::class.java).items + } catch (ioe: IOException) { + Log.w(TAG, ioe) + } + } + + return emptySet() + } + + private fun getQuote(cursor: Cursor): Quote? { + val quoteId = cursor.requireLong(QUOTE_ID) + val quoteAuthor = cursor.requireLong(QUOTE_AUTHOR) + var quoteText: CharSequence? = cursor.requireString(QUOTE_BODY) + val quoteType = cursor.requireInt(QUOTE_TYPE) + val quoteMissing = cursor.requireBoolean(QUOTE_MISSING) + var quoteMentions = parseQuoteMentions(cursor) + val bodyRanges = parseQuoteBodyRanges(cursor) + + val attachments = attachments.getAttachments(cursor) + val quoteAttachments: List = attachments.filter { it.isQuote } + val quoteDeck = SlideDeck(context, quoteAttachments) + + return if (quoteId > 0 && quoteAuthor > 0) { + if (quoteText != null && (quoteMentions.isNotEmpty() || bodyRanges != null)) { + val updated: UpdatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions) + val styledText = SpannableString(updated.body) + + style(bodyRanges.adjustBodyRanges(updated.bodyAdjustments), styledText) + + quoteText = styledText + quoteMentions = updated.mentions + } + + Quote( + quoteId, + RecipientId.from(quoteAuthor), + quoteText, + quoteMissing, + quoteDeck, + quoteMentions, + QuoteModel.Type.fromCode(quoteType) + ) + } else { + null + } + } + + private fun String?.toIsoBytes(): ByteArray? { + return if (this != null && this.isNotEmpty()) { + Util.toIsoBytes(this) + } else { + null + } + } + + private inner class ReaderIterator : Iterator { + override fun hasNext(): Boolean { + return cursor.count != 0 && !cursor.isLast + } + + override fun next(): MessageRecord { + return getNext() ?: throw NoSuchElementException() + } + } + + companion object { + + @JvmStatic + fun buildSlideDeck(context: Context, attachments: List): SlideDeck { + val messageAttachments = attachments + .filterNot { it.isQuote } + .sortedWith(DisplayOrderComparator()) + + return SlideDeck(context, messageAttachments) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt index 44a9e35492..1a31005a1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt @@ -128,7 +128,7 @@ class SearchTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa * Be smart about where you call this. */ fun rebuildIndex(batchSize: Long = 10_000L) { - val maxId: Long = SignalDatabase.messages.nextId + val maxId: Long = SignalDatabase.messages.getNextId() Log.i(TAG, "Re-indexing. Operating on ID's 1-$maxId in steps of $batchSize.") diff --git a/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportReader.kt b/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportReader.kt index 6abe9c3def..7887ebcaf3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportReader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/exporter/SignalSmsExportReader.kt @@ -38,7 +38,7 @@ class SignalSmsExportReader( } fun getCount(): Int { - return messageTable.unexportedInsecureMessagesCount + return messageTable.getUnexportedInsecureMessagesCount() } override fun close() { @@ -51,7 +51,7 @@ class SignalSmsExportReader( messageReader = null val refreshedMmsReader = MessageTable.mmsReaderFor(messageTable.getUnexportedInsecureMessages(CURSOR_LIMIT)) - if (refreshedMmsReader.count > 0) { + if (refreshedMmsReader.getCount() > 0) { messageReader = refreshedMmsReader return } else { @@ -88,14 +88,14 @@ class SignalSmsExportReader( try { return if (messageIterator?.hasNext() == true) { record = messageIterator!!.next() - readExportableMmsMessageFromRecord(record, messageReader!!.messageExportStateForCurrentRecord) + readExportableMmsMessageFromRecord(record, messageReader!!.getMessageExportStateForCurrentRecord()) } else { throw NoSuchElementException() } } catch (e: Throwable) { if (e.cause is JSONException) { Log.w(TAG, "Error processing attachment json, skipping message.", e) - return ExportableMessage.Skip(messageReader!!.currentId) + return ExportableMessage.Skip(messageReader!!.getCurrentId()) } Log.w(TAG, "Error processing message: isMms: ${record?.isMms} type: ${record?.type}") diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java b/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java deleted file mode 100644 index d6d636e651..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.java +++ /dev/null @@ -1,118 +0,0 @@ -package org.thoughtcrime.securesms.mms; - - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.thoughtcrime.securesms.attachments.Attachment; -import org.thoughtcrime.securesms.database.model.Mention; -import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; - -import java.util.Collections; -import java.util.List; - -public class QuoteModel { - - private final long id; - private final RecipientId author; - private final String text; - private final boolean missing; - private final List attachments; - private final List mentions; - private final Type type; - private final BodyRangeList bodyRanges; - - public QuoteModel(long id, - @NonNull RecipientId author, - String text, - boolean missing, - @Nullable List attachments, - @Nullable List mentions, - @NonNull Type type, - @Nullable BodyRangeList bodyRanges) - { - this.id = id; - this.author = author; - this.text = text; - this.missing = missing; - this.attachments = attachments; - this.mentions = mentions != null ? mentions : Collections.emptyList(); - this.type = type; - this.bodyRanges = bodyRanges; - } - - public long getId() { - return id; - } - - public RecipientId getAuthor() { - return author; - } - - public String getText() { - return text; - } - - public boolean isOriginalMissing() { - return missing; - } - - public List getAttachments() { - return attachments; - } - - public @NonNull List getMentions() { - return mentions; - } - - public Type getType() { - return type; - } - - public @Nullable BodyRangeList getBodyRanges() { - return bodyRanges; - } - - public enum Type { - NORMAL(0, SignalServiceDataMessage.Quote.Type.NORMAL), - GIFT_BADGE(1, SignalServiceDataMessage.Quote.Type.GIFT_BADGE); - - private final int code; - private final SignalServiceDataMessage.Quote.Type dataMessageType; - - Type(int code, @NonNull SignalServiceDataMessage.Quote.Type dataMessageType) { - this.code = code; - this.dataMessageType = dataMessageType; - } - - public int getCode() { - return code; - } - - public @NonNull SignalServiceDataMessage.Quote.Type getDataMessageType() { - return dataMessageType; - } - - public static Type fromCode(int code) { - for (final Type value : values()) { - if (value.code == code) { - return value; - } - } - - throw new IllegalArgumentException("Invalid code: " + code); - } - - public static Type fromDataMessageType(@NonNull SignalServiceDataMessage.Quote.Type dataMessageType) { - for (final Type value : values()) { - if (value.dataMessageType == dataMessageType) { - return value; - } - } - - return NORMAL; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.kt new file mode 100644 index 0000000000..e3403cd1da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/QuoteModel.kt @@ -0,0 +1,52 @@ +package org.thoughtcrime.securesms.mms + +import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage + +class QuoteModel( + val id: Long, + val author: RecipientId, + val text: String, + val isOriginalMissing: Boolean, + val attachments: List, + mentions: List?, + val type: Type, + val bodyRanges: BodyRangeList? +) { + val mentions: List + + init { + this.mentions = mentions ?: emptyList() + } + + enum class Type(val code: Int, val dataMessageType: SignalServiceDataMessage.Quote.Type) { + + NORMAL(0, SignalServiceDataMessage.Quote.Type.NORMAL), + GIFT_BADGE(1, SignalServiceDataMessage.Quote.Type.GIFT_BADGE); + + companion object { + @JvmStatic + fun fromCode(code: Int): Type { + for (value in values()) { + if (value.code == code) { + return value + } + } + throw IllegalArgumentException("Invalid code: $code") + } + + @JvmStatic + fun fromDataMessageType(dataMessageType: SignalServiceDataMessage.Quote.Type): Type { + for (value in values()) { + if (value.dataMessageType === dataMessageType) { + return value + } + } + return NORMAL + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt index 6561d30bab..9c29b7e2c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/v2/NotificationStateProvider.kt @@ -33,7 +33,7 @@ object NotificationStateProvider { } MessageTable.mmsReaderFor(unreadMessages).use { reader -> - var record: MessageRecord? = reader.next + var record: MessageRecord? = reader.getNext() while (record != null) { val threadRecipient: Recipient? = SignalDatabase.threads.getRecipientForThreadId(record.threadId) if (threadRecipient != null) { @@ -73,7 +73,7 @@ object NotificationStateProvider { ) } try { - record = reader.next + record = reader.getNext() } catch (e: IllegalStateException) { // XXX Weird SQLCipher bug that's being investigated record = null 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 8d3d0caa46..0ade8786af 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 @@ -34,7 +34,7 @@ class ConversationListTabRepository { fun getNumberOfUnseenStories(): Observable { return Observable.create { emitter -> fun refresh() { - emitter.onNext(SignalDatabase.messages.unreadStoryThreadRecipientIds.map { Recipient.resolved(it) }.filterNot { it.shouldHideStory() }.size.toLong()) + emitter.onNext(SignalDatabase.messages.getUnreadStoryThreadRecipientIds().map { Recipient.resolved(it) }.filterNot { it.shouldHideStory() }.size.toLong()) } val listener = DatabaseObserver.Observer { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt index e3b2fa74f3..8a32bc77f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt @@ -38,7 +38,7 @@ open class StoryViewerRepository { } fun getStories(hiddenStories: Boolean, isOutgoingOnly: Boolean): Single> { - return Single.create> { emitter -> + return Single.create { emitter -> val myStoriesId = SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY) val myStories = Recipient.resolved(myStoriesId) val releaseChannelId = SignalStore.releaseChannelValues().releaseChannelRecipientId diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt index 62348decf3..cbe83f63d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyDataSource.kt @@ -20,7 +20,7 @@ class StoryGroupReplyDataSource(private val parentStoryId: Long) : PagedDataSour cursor.moveToPosition(start - 1) val mmsReader = MessageTable.MmsReader(cursor) while (cursor.moveToNext() && cursor.position < start + length) { - results.add(readRowFromRecord(mmsReader.current as MmsMessageRecord)) + results.add(readRowFromRecord(mmsReader.getCurrent() as MmsMessageRecord)) } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/testing/ProxySignalSQLiteDatabase.kt b/app/src/test/java/org/thoughtcrime/securesms/testing/ProxySignalSQLiteDatabase.kt index 99d8fd4aa5..88dd08db0f 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/testing/ProxySignalSQLiteDatabase.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/testing/ProxySignalSQLiteDatabase.kt @@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.testing import android.content.ContentValues import android.database.Cursor +import androidx.sqlite.db.SupportSQLiteQuery +import org.signal.core.util.toAndroidQuery import java.util.Locale import android.database.sqlite.SQLiteDatabase as AndroidSQLiteDatabase import android.database.sqlite.SQLiteTransactionListener as AndroidSQLiteTransactionListener @@ -49,6 +51,11 @@ class ProxySignalSQLiteDatabase(private val database: AndroidSQLiteDatabase) : S return database.queryWithFactory(null, distinct, table, columns, selection, selectionArgs, groupBy, having, orderBy, limit) } + override fun query(query: SupportSQLiteQuery): Cursor? { + val converted = query.toAndroidQuery() + return database.rawQuery(converted.where, converted.whereArgs) + } + override fun query(table: String?, columns: Array?, selection: String?, selectionArgs: Array?, groupBy: String?, having: String?, orderBy: String?): Cursor { return database.query(table, columns, selection, selectionArgs, groupBy, having, orderBy) } diff --git a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt index f00b91f009..9c142091bf 100644 --- a/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/CursorExtensions.kt @@ -162,4 +162,12 @@ inline fun Cursor.firstOrNull(predicate: (T) -> Boolean = { true }, mapper: return null } +inline fun Cursor.forEach(operation: (Cursor) -> Unit) { + use { + while (moveToNext()) { + operation(this) + } + } +} + fun Boolean.toInt(): Int = if (this) 1 else 0 diff --git a/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt b/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt index 916ae0008c..2d3d48a4ae 100644 --- a/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/SQLiteDatabaseExtensions.kt @@ -70,6 +70,10 @@ fun SupportSQLiteDatabase.delete(tableName: String): DeleteBuilderPart1 { return DeleteBuilderPart1(this, tableName) } +fun SupportSQLiteDatabase.insertInto(tableName: String): InsertBuilderPart1 { + return InsertBuilderPart1(this, tableName) +} + class SelectBuilderPart1( private val db: SupportSQLiteDatabase, private val columns: Array @@ -117,6 +121,10 @@ class SelectBuilderPart3( return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, limit.toString()) } + fun limit(limit: String): SelectBuilderPart4b { + return SelectBuilderPart4b(db, columns, tableName, where, whereArgs, limit) + } + fun run(): Cursor { return db.query( SupportSQLiteQueryBuilder @@ -140,6 +148,10 @@ class SelectBuilderPart4a( return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, limit.toString()) } + fun limit(limit: String): SelectBuilderPart5 { + return SelectBuilderPart5(db, columns, tableName, where, whereArgs, orderBy, limit) + } + fun run(): Cursor { return db.query( SupportSQLiteQueryBuilder @@ -220,6 +232,10 @@ class UpdateBuilderPart2( return UpdateBuilderPart3(db, tableName, values, where, SqlUtil.buildArgs(*whereArgs)) } + fun where(@Language("sql") where: String, whereArgs: Array): UpdateBuilderPart3 { + return UpdateBuilderPart3(db, tableName, values, where, whereArgs) + } + fun run(conflictStrategy: Int = SQLiteDatabase.CONFLICT_NONE): Int { return db.update(tableName, conflictStrategy, values, null, null) } @@ -246,6 +262,10 @@ class DeleteBuilderPart1( return DeleteBuilderPart2(db, tableName, where, SqlUtil.buildArgs(*whereArgs)) } + fun where(@Language("sql") where: String, whereArgs: Array): DeleteBuilderPart2 { + return DeleteBuilderPart2(db, tableName, where, whereArgs) + } + fun run(): Int { return db.delete(tableName, null, null) } @@ -271,6 +291,10 @@ class ExistsBuilderPart1( return ExistsBuilderPart2(db, tableName, where, SqlUtil.buildArgs(*whereArgs)) } + fun where(@Language("sql") where: String, whereArgs: Array): ExistsBuilderPart2 { + return ExistsBuilderPart2(db, tableName, where, whereArgs) + } + fun run(): Boolean { return db.query("SELECT EXISTS(SELECT 1 FROM $tableName)", null).use { cursor -> cursor.moveToFirst() && cursor.getInt(0) == 1 @@ -290,3 +314,26 @@ class ExistsBuilderPart2( } } } + +class InsertBuilderPart1( + private val db: SupportSQLiteDatabase, + private val tableName: String +) { + + fun values(values: ContentValues): InsertBuilderPart2 { + return InsertBuilderPart2(db, tableName, values) + } + fun values(vararg values: Pair): InsertBuilderPart2 { + return InsertBuilderPart2(db, tableName, contentValuesOf(*values)) + } +} + +class InsertBuilderPart2( + private val db: SupportSQLiteDatabase, + private val tableName: String, + private val values: ContentValues +) { + fun run(conflictStrategy: Int = SQLiteDatabase.CONFLICT_NONE): Long { + return db.insert(tableName, conflictStrategy, values) + } +} diff --git a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt index a100cbcddf..9149bf4768 100644 --- a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt +++ b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt @@ -244,11 +244,13 @@ object SqlUtil { @JvmOverloads @JvmStatic fun buildCollectionQuery(column: String, values: Collection, prefix: String = "", maxSize: Int = MAX_QUERY_ARGS): List { - require(!values.isEmpty()) { "Must have values!" } - - return values - .chunked(maxSize) - .map { batch -> buildSingleCollectionQuery(column, batch, prefix) } + return if (values.isEmpty()) { + emptyList() + } else { + values + .chunked(maxSize) + .map { batch -> buildSingleCollectionQuery(column, batch, prefix) } + } } /** diff --git a/core-util/src/main/java/org/signal/core/util/StringExtensions.kt b/core-util/src/main/java/org/signal/core/util/StringExtensions.kt index 7cc0a2cf7f..ab9d7c9fda 100644 --- a/core-util/src/main/java/org/signal/core/util/StringExtensions.kt +++ b/core-util/src/main/java/org/signal/core/util/StringExtensions.kt @@ -38,3 +38,7 @@ fun String.asListContains(item: String): Boolean { fun String.toSingleLine(): String { return this.trimIndent().split("\n").joinToString(separator = " ") } + +fun String?.emptyIfNull(): String { + return this ?: "" +} diff --git a/core-util/src/main/java/org/signal/core/util/SupportSQLiteQueryExtensions.kt b/core-util/src/main/java/org/signal/core/util/SupportSQLiteQueryExtensions.kt new file mode 100644 index 0000000000..62b0a78ec5 --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/SupportSQLiteQueryExtensions.kt @@ -0,0 +1,47 @@ +package org.signal.core.util + +import androidx.sqlite.db.SupportSQLiteProgram +import androidx.sqlite.db.SupportSQLiteQuery + +fun SupportSQLiteQuery.toAndroidQuery(): SqlUtil.Query { + val program = CapturingSqliteProgram(this.argCount) + this.bindTo(program) + return SqlUtil.Query(this.sql, program.args()) +} + +private class CapturingSqliteProgram(count: Int) : SupportSQLiteProgram { + private val args: Array = arrayOfNulls(count) + + fun args(): Array { + return args.filterNotNull().toTypedArray() + } + + override fun close() { + } + + override fun bindNull(index: Int) { + throw UnsupportedOperationException() + } + + override fun bindLong(index: Int, value: Long) { + args[index - 1] = value.toString() + } + + override fun bindDouble(index: Int, value: Double) { + args[index - 1] = value.toString() + } + + override fun bindString(index: Int, value: String?) { + args[index - 1] = value + } + + override fun bindBlob(index: Int, value: ByteArray?) { + throw UnsupportedOperationException() + } + + override fun clearBindings() { + for (i in args.indices) { + args[i] = null + } + } +} diff --git a/core-util/src/test/java/org/signal/core/util/SqlUtilTest.java b/core-util/src/test/java/org/signal/core/util/SqlUtilTest.java index c35d6125e6..a7c9e1f993 100644 --- a/core-util/src/test/java/org/signal/core/util/SqlUtilTest.java +++ b/core-util/src/test/java/org/signal/core/util/SqlUtilTest.java @@ -18,6 +18,7 @@ import java.util.List; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; @RunWith(RobolectricTestRunner.class) @Config(manifest = Config.NONE, application = Application.class) @@ -164,9 +165,9 @@ public final class SqlUtilTest { assertArrayEquals(new String[] { "1", "2", "3" }, updateQuery.get(0).getWhereArgs()); } - @Test(expected = IllegalArgumentException.class) public void buildCollectionQuery_none() { - SqlUtil.buildCollectionQuery("a", Collections.emptyList()); + List results = SqlUtil.buildCollectionQuery("a", Collections.emptyList()); + assertTrue(results.isEmpty()); } @Test