diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt new file mode 100644 index 0000000000..9606456a5e --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/BackupTest.kt @@ -0,0 +1,585 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2 + +import android.content.ContentValues +import android.database.Cursor +import net.zetetic.database.sqlcipher.SQLiteDatabase +import org.junit.Before +import org.junit.Test +import org.signal.core.util.Hex +import org.signal.core.util.SqlUtil +import org.signal.core.util.insertInto +import org.signal.core.util.readToList +import org.signal.core.util.readToSingleObject +import org.signal.core.util.requireBlob +import org.signal.core.util.requireLong +import org.signal.core.util.requireString +import org.signal.core.util.select +import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore +import org.thoughtcrime.securesms.database.EmojiSearchTable +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.MessageTypes +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.subscription.Subscriber +import org.thoughtcrime.securesms.testing.assertIs +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.push.ServiceId.PNI +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import java.util.UUID +import kotlin.random.Random + +typealias DatabaseData = Map>> + +class BackupTest { + companion object { + val SELF_ACI = ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641")) + val SELF_PNI = PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910")) + const val SELF_E164 = "+10000000000" + val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32)) + + val ALICE_ACI = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e")) + val ALICE_PNI = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999")) + val ALICE_E164 = "+12222222222" + + /** Columns that we don't need to check equality of */ + private val IGNORED_COLUMNS: Map> = mapOf( + RecipientTable.TABLE_NAME to setOf(RecipientTable.STORAGE_SERVICE_ID) + ) + + /** Tables we don't need to check equality of */ + private val IGNORED_TABLES: Set = setOf( + EmojiSearchTable.TABLE_NAME, + "sqlite_sequence", + "message_fts_data", + "message_fts_idx", + "message_fts_docsize" + ) + } + + @Before + fun setup() { + SignalStore.account().setE164(SELF_E164) + SignalStore.account().setAci(SELF_ACI) + SignalStore.account().setPni(SELF_PNI) + SignalStore.account().generateAciIdentityKeyIfNecessary() + SignalStore.account().generatePniIdentityKeyIfNecessary() + } + + @Test + fun emptyDatabase() { + backupTest { } + } + + @Test + fun noteToSelf() { + backupTest { + individualChat(aci = SELF_ACI, givenName = "Note to Self") { + standardMessage(outgoing = true, body = "A") + standardMessage(outgoing = true, body = "B") + standardMessage(outgoing = true, body = "C") + } + } + } + + @Test + fun individualChat() { + backupTest { + individualChat(aci = ALICE_ACI, givenName = "Alice") { + val m1 = standardMessage(outgoing = true, body = "Outgoing 1") + val m2 = standardMessage(outgoing = false, body = "Incoming 1", read = true) + standardMessage(outgoing = true, body = "Outgoing 2", quotes = m2) + standardMessage(outgoing = false, body = "Incoming 2", quotes = m1, quoteTargetMissing = true, read = false) + standardMessage(outgoing = true, body = "Outgoing 3, with mention", randomMention = true) + standardMessage(outgoing = false, body = "Incoming 3, with style", read = false, randomStyling = true) + remoteDeletedMessage(outgoing = true) + remoteDeletedMessage(outgoing = false) + } + } + } + + @Test + fun individualRecipients() { + backupTest { + // Comprehensive example + individualRecipient( + aci = ALICE_ACI, + pni = ALICE_PNI, + e164 = ALICE_E164, + givenName = "Alice", + familyName = "Smith", + username = "alice.99", + hidden = false, + registeredState = RecipientTable.RegisteredState.REGISTERED, + profileKey = ProfileKey(Random.nextBytes(32)), + profileSharing = true, + hideStory = false + ) + + // Trying to get coverage of all the various values + individualRecipient(aci = ACI.from(UUID.randomUUID()), registeredState = RecipientTable.RegisteredState.NOT_REGISTERED) + individualRecipient(aci = ACI.from(UUID.randomUUID()), registeredState = RecipientTable.RegisteredState.UNKNOWN) + individualRecipient(pni = PNI.from(UUID.randomUUID())) + individualRecipient(e164 = "+15551234567") + individualRecipient(aci = ACI.from(UUID.randomUUID()), givenName = "Bob") + individualRecipient(aci = ACI.from(UUID.randomUUID()), familyName = "Smith") + individualRecipient(aci = ACI.from(UUID.randomUUID()), profileSharing = false) + individualRecipient(aci = ACI.from(UUID.randomUUID()), hideStory = true) + individualRecipient(aci = ACI.from(UUID.randomUUID()), hidden = true) + } + } + + @Test + fun accountData() { + val context = ApplicationDependencies.getApplication() + + backupTest(validateKeyValue = true) { + val self = Recipient.self() + + // TODO note-to-self archived + // TODO note-to-self unread + + SignalStore.account().setAci(SELF_ACI) + SignalStore.account().setPni(SELF_PNI) + SignalStore.account().setE164(SELF_E164) + SignalStore.account().generateAciIdentityKeyIfNecessary() + SignalStore.account().generatePniIdentityKeyIfNecessary() + + SignalDatabase.recipients.setProfileKey(self.id, ProfileKey(Random.nextBytes(32))) + SignalDatabase.recipients.setProfileName(self.id, ProfileName.fromParts("Peter", "Parker")) + SignalDatabase.recipients.setProfileAvatar(self.id, "https://example.com/") + + SignalStore.donationsValues().markUserManuallyCancelled() + SignalStore.donationsValues().setSubscriber(Subscriber(SubscriberId.generate(), "USD")) + SignalStore.donationsValues().setDisplayBadgesOnProfile(false) + + SignalStore.phoneNumberPrivacy().phoneNumberListingMode = PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED + SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY + + SignalStore.settings().isLinkPreviewsEnabled = false + SignalStore.settings().isPreferSystemContactPhotos = true + SignalStore.settings().universalExpireTimer = 42 + SignalStore.settings().setKeepMutedChatsArchived(true) + + SignalStore.storyValues().viewedReceiptsEnabled = false + SignalStore.storyValues().userHasReadOnboardingStory = true + SignalStore.storyValues().userHasViewedOnboardingStory = true + SignalStore.storyValues().isFeatureDisabled = false + SignalStore.storyValues().userHasBeenNotifiedAboutStories = true + SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = true + + SignalStore.emojiValues().reactions = listOf("a", "b", "c") + + TextSecurePreferences.setTypingIndicatorsEnabled(context, false) + TextSecurePreferences.setReadReceiptsEnabled(context, false) + TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, true) + } + + // Have to check TextSecurePreferences ourselves, since they're not in a database + TextSecurePreferences.isTypingIndicatorsEnabled(context) assertIs false + TextSecurePreferences.isReadReceiptsEnabled(context) assertIs false + TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context) assertIs true + } + + /** + * Sets up the database, then executes your setup code, then compares snapshots of the database + * before an after an import to ensure that no data was lost/changed. + * + * @param validateKeyValue If true, this will also validate the KeyValueDatabase. You only want to do this if you + * intend on setting most of the values. Otherwise stuff tends to not match since values are lazily written. + */ + private fun backupTest(validateKeyValue: Boolean = false, content: () -> Unit) { + // Under normal circumstances, My Story ends up being the first recipient in the table, and is added automatically. + // This screws with the tests by offsetting all the recipientIds in the initial state. + // Easiest way to get around this is to make the DB a true clean slate by clearing everything. + // (We only really need to clear Recipient/dlists, but doing everything to be consistent.) + SignalDatabase.distributionLists.clearAllDataForBackupRestore() + SignalDatabase.recipients.clearAllDataForBackupRestore() + SignalDatabase.messages.clearAllDataForBackupRestore() + SignalDatabase.threads.clearAllDataForBackupRestore() + + // Again, for comparison purposes, because we always import self first, we want to ensure it's the first item + // in the table when we export. + individualRecipient( + aci = SELF_ACI, + pni = SELF_PNI, + e164 = SELF_E164, + profileKey = SELF_PROFILE_KEY, + profileSharing = true + ) + + content() + + val startingMainData: DatabaseData = SignalDatabase.rawDatabase.readAllContents() + val startingKeyValueData: DatabaseData = if (validateKeyValue) SignalDatabase.rawDatabase.readAllContents() else emptyMap() + + BackupRepository.import(BackupRepository.export()) + + val endingData: DatabaseData = SignalDatabase.rawDatabase.readAllContents() + val endingKeyValueData: DatabaseData = if (validateKeyValue) SignalDatabase.rawDatabase.readAllContents() else emptyMap() + + assertDatabaseMatches(startingMainData, endingData) + assertDatabaseMatches(startingKeyValueData, endingKeyValueData) + } + + private fun individualChat(aci: ACI, givenName: String, familyName: String? = null, init: IndividualChatCreator.() -> Unit) { + val recipientId = individualRecipient(aci = aci, givenName = givenName, familyName = familyName, profileSharing = true) + + val threadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(recipientId, false) + + IndividualChatCreator(SignalDatabase.rawDatabase, recipientId, threadId).init() + + SignalDatabase.threads.update(threadId, false) + } + + private fun individualRecipient( + aci: ACI? = null, + pni: PNI? = null, + e164: String? = null, + givenName: String? = null, + familyName: String? = null, + username: String? = null, + hidden: Boolean = false, + registeredState: RecipientTable.RegisteredState = RecipientTable.RegisteredState.UNKNOWN, + profileKey: ProfileKey? = null, + profileSharing: Boolean = false, + hideStory: Boolean = false + ): RecipientId { + check(aci != null || pni != null || e164 != null) + + val recipientId = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164, pniVerified = true, changeSelf = true) + + if (givenName != null || familyName != null) { + SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts(givenName, familyName)) + } + + if (username != null) { + SignalDatabase.recipients.setUsername(recipientId, username) + } + + if (registeredState == RecipientTable.RegisteredState.REGISTERED) { + SignalDatabase.recipients.markRegistered(recipientId, aci ?: pni!!) + } else if (registeredState == RecipientTable.RegisteredState.NOT_REGISTERED) { + SignalDatabase.recipients.markUnregistered(recipientId) + } + + if (profileKey != null) { + SignalDatabase.recipients.setProfileKey(recipientId, profileKey) + } + + SignalDatabase.recipients.setProfileSharing(recipientId, profileSharing) + SignalDatabase.recipients.setHideStory(recipientId, hideStory) + + if (hidden) { + SignalDatabase.recipients.markHidden(recipientId) + } + + return recipientId + } + + private inner class IndividualChatCreator( + private val db: SQLiteDatabase, + private val recipientId: RecipientId, + private val threadId: Long + ) { + fun standardMessage( + outgoing: Boolean, + sentTimestamp: Long = System.currentTimeMillis(), + receivedTimestamp: Long = sentTimestamp + 1, + serverTimestamp: Long = sentTimestamp, + body: String? = null, + read: Boolean = true, + quotes: Long? = null, + quoteTargetMissing: Boolean = false, + randomMention: Boolean = false, + randomStyling: Boolean = false + ): Long { + return db.insertMessage( + from = if (outgoing) Recipient.self().id else recipientId, + to = if (outgoing) recipientId else Recipient.self().id, + outgoing = outgoing, + threadId = threadId, + sentTimestamp = sentTimestamp, + receivedTimestamp = receivedTimestamp, + serverTimestamp = serverTimestamp, + body = body, + read = read, + quotes = quotes, + quoteTargetMissing = quoteTargetMissing, + randomMention = randomMention, + randomStyling = randomStyling + ) + } + + fun remoteDeletedMessage( + outgoing: Boolean, + sentTimestamp: Long = System.currentTimeMillis(), + receivedTimestamp: Long = sentTimestamp + 1, + serverTimestamp: Long = sentTimestamp + ): Long { + return db.insertMessage( + from = if (outgoing) Recipient.self().id else recipientId, + to = if (outgoing) recipientId else Recipient.self().id, + outgoing = outgoing, + threadId = threadId, + sentTimestamp = sentTimestamp, + receivedTimestamp = receivedTimestamp, + serverTimestamp = serverTimestamp, + remoteDeleted = true + ) + } + } + + private fun SQLiteDatabase.insertMessage( + from: RecipientId, + to: RecipientId, + outgoing: Boolean, + threadId: Long, + sentTimestamp: Long = System.currentTimeMillis(), + receivedTimestamp: Long = sentTimestamp + 1, + serverTimestamp: Long = sentTimestamp, + body: String? = null, + read: Boolean = true, + quotes: Long? = null, + quoteTargetMissing: Boolean = false, + randomMention: Boolean = false, + randomStyling: Boolean = false, + remoteDeleted: Boolean = false + ): Long { + val type = if (outgoing) { + MessageTypes.BASE_SENT_TYPE + } else { + MessageTypes.BASE_INBOX_TYPE + } or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT + + val contentValues = ContentValues() + contentValues.put(MessageTable.DATE_SENT, sentTimestamp) + contentValues.put(MessageTable.DATE_RECEIVED, receivedTimestamp) + contentValues.put(MessageTable.FROM_RECIPIENT_ID, from.serialize()) + contentValues.put(MessageTable.TO_RECIPIENT_ID, to.serialize()) + contentValues.put(MessageTable.THREAD_ID, threadId) + contentValues.put(MessageTable.BODY, body) + contentValues.put(MessageTable.TYPE, type) + contentValues.put(MessageTable.READ, if (read) 1 else 0) + + if (!outgoing) { + contentValues.put(MessageTable.DATE_SERVER, serverTimestamp) + } + + if (remoteDeleted) { + contentValues.put(MessageTable.REMOTE_DELETED, 1) + return this + .insertInto(MessageTable.TABLE_NAME) + .values(contentValues) + .run() + } + + if (quotes != null) { + val quoteDetails = this.getQuoteDetailsFor(quotes) + contentValues.put(MessageTable.QUOTE_ID, quoteDetails.quotedSentTimestamp) + contentValues.put(MessageTable.QUOTE_AUTHOR, quoteDetails.authorId.serialize()) + contentValues.put(MessageTable.QUOTE_BODY, quoteDetails.body) + contentValues.put(MessageTable.QUOTE_BODY_RANGES, quoteDetails.bodyRanges) + contentValues.put(MessageTable.QUOTE_TYPE, quoteDetails.type) + contentValues.put(MessageTable.QUOTE_MISSING, quoteTargetMissing) + } + + if (body != null && (randomMention || randomStyling)) { + val ranges: MutableList = mutableListOf() + + if (randomMention) { + ranges += BodyRangeList.BodyRange( + start = 0, + length = Random.nextInt(body.length), + mentionUuid = if (outgoing) Recipient.resolved(to).requireAci().toString() else Recipient.resolved(from).requireAci().toString() + ) + } + + if (randomStyling) { + ranges += BodyRangeList.BodyRange( + start = 0, + length = Random.nextInt(body.length), + style = BodyRangeList.BodyRange.Style.fromValue(Random.nextInt(BodyRangeList.BodyRange.Style.values().size)) + ) + } + + contentValues.put(MessageTable.MESSAGE_RANGES, BodyRangeList(ranges = ranges).encode()) + } + + return this + .insertInto(MessageTable.TABLE_NAME) + .values(contentValues) + .run() + } + + private fun assertDatabaseMatches(expected: DatabaseData, actual: DatabaseData) { + assert(expected.keys.size == actual.keys.size) { "Mismatched table count! Expected: ${expected.keys} || Actual: ${actual.keys}" } + assert(expected.keys.containsAll(actual.keys)) { "Table names differ! Expected: ${expected.keys} || Actual: ${actual.keys}" } + + val tablesToCheck = expected.keys.filter { !IGNORED_TABLES.contains(it) } + + for (table in tablesToCheck) { + val expectedTable: List> = expected[table]!! + val actualTable: List> = actual[table]!! + + assert(expectedTable.size == actualTable.size) { "Mismatched number of rows for table '$table'! Expected: ${expectedTable.size} || Actual: ${actualTable.size}\n $actualTable" } + + val expectedFiltered: List> = expectedTable.withoutExcludedColumns(IGNORED_COLUMNS[table]) + val actualFiltered: List> = actualTable.withoutExcludedColumns(IGNORED_COLUMNS[table]) + + assert(contentEquals(expectedFiltered, actualFiltered)) { "Data did not match for table '$table'!\n${prettyDiff(expectedFiltered, actualFiltered)}" } + } + } + + private fun contentEquals(expectedRows: List>, actualRows: List>): Boolean { + if (expectedRows == actualRows) { + return true + } + + assert(expectedRows.size == actualRows.size) + + for (i in expectedRows.indices) { + val expectedRow = expectedRows[i] + val actualRow = actualRows[i] + + for (key in expectedRow.keys) { + val expectedValue = expectedRow[key] + val actualValue = actualRow[key] + + if (!contentEquals(expectedValue, actualValue)) { + return false + } + } + } + + return true + } + + private fun contentEquals(lhs: Any?, rhs: Any?): Boolean { + return if (lhs is ByteArray && rhs is ByteArray) { + lhs.contentEquals(rhs) + } else { + lhs == rhs + } + } + + private fun prettyDiff(expectedRows: List>, actualRows: List>): String { + val builder = StringBuilder() + + assert(expectedRows.size == actualRows.size) + + for (i in expectedRows.indices) { + val expectedRow = expectedRows[i] + val actualRow = actualRows[i] + var describedRow = false + + for (key in expectedRow.keys) { + val expectedValue = expectedRow[key] + val actualValue = actualRow[key] + + if (!contentEquals(expectedValue, actualValue)) { + if (!describedRow) { + builder.append("-- ROW $i\n") + describedRow = true + } + builder.append("  [$key] Expected: ${expectedValue.prettyPrint()} || Actual: ${actualValue.prettyPrint()} \n") + } + } + + if (describedRow) { + builder.append("\n") + } + } + + return builder.toString() + } + + private fun Any?.prettyPrint(): String { + return when (this) { + is ByteArray -> "Bytes(${Hex.toString(this)})" + else -> this.toString() + } + } + + private fun List>.withoutExcludedColumns(ignored: Set?): List> { + return if (ignored != null) { + this.map { row -> + row.filterKeys { !ignored.contains(it) } + } + } else { + this + } + } + + private fun SQLiteDatabase.getQuoteDetailsFor(messageId: Long): QuoteDetails { + return this + .select( + MessageTable.DATE_SENT, + MessageTable.FROM_RECIPIENT_ID, + MessageTable.BODY, + MessageTable.MESSAGE_RANGES + ) + .from(MessageTable.TABLE_NAME) + .where("${MessageTable.ID} = ?", messageId) + .run() + .readToSingleObject { cursor -> + QuoteDetails( + quotedSentTimestamp = cursor.requireLong(MessageTable.DATE_SENT), + authorId = RecipientId.from(cursor.requireLong(MessageTable.FROM_RECIPIENT_ID)), + body = cursor.requireString(MessageTable.BODY), + bodyRanges = cursor.requireBlob(MessageTable.MESSAGE_RANGES), + type = QuoteModel.Type.NORMAL.code + ) + }!! + } + + private fun SQLiteDatabase.readAllContents(): DatabaseData { + return SqlUtil.getAllTables(this).associateWith { table -> this.getAllTableData(table) } + } + + private fun SQLiteDatabase.getAllTableData(table: String): List> { + return this + .select() + .from(table) + .run() + .readToList { cursor -> + val map: MutableMap = mutableMapOf() + + for (i in 0 until cursor.columnCount) { + val column = cursor.getColumnName(i) + + when (cursor.getType(i)) { + Cursor.FIELD_TYPE_INTEGER -> map[column] = cursor.getInt(i) + Cursor.FIELD_TYPE_FLOAT -> map[column] = cursor.getFloat(i) + Cursor.FIELD_TYPE_STRING -> map[column] = cursor.getString(i) + Cursor.FIELD_TYPE_BLOB -> map[column] = cursor.getBlob(i) + Cursor.FIELD_TYPE_NULL -> map[column] = null + } + } + + map + } + } + + private data class QuoteDetails( + val quotedSentTimestamp: Long, + val authorId: RecipientId, + val body: String?, + val bodyRanges: ByteArray?, + val type: Int + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt new file mode 100644 index 0000000000..8babf88e79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2 + +import org.signal.core.util.EventTimer +import org.signal.core.util.logging.Log +import org.signal.core.util.withinTransaction +import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter +import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore +import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor +import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor +import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor +import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor +import org.thoughtcrime.securesms.backup.v2.stream.BackupExportStream +import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupExportStream +import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupImportStream +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.RecipientId +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +object BackupRepository { + + private val TAG = Log.tag(BackupRepository::class.java) + + fun export(): ByteArray { + val eventTimer = EventTimer() + + val outputStream = ByteArrayOutputStream() + val writer: BackupExportStream = PlainTextBackupExportStream(outputStream) + + // Note: Without a transaction, we may export inconsistent state. But because we have a transaction, + // writes from other threads are blocked. This is something to think more about. + SignalDatabase.rawDatabase.withinTransaction { + AccountDataProcessor.export { + writer.write(it) + eventTimer.emit("account") + } + + RecipientBackupProcessor.export { + writer.write(it) + eventTimer.emit("recipient") + } + + ChatBackupProcessor.export { frame -> + writer.write(frame) + eventTimer.emit("thread") + } + + ChatItemBackupProcessor.export { frame -> + writer.write(frame) + eventTimer.emit("message") + } + } + + Log.d(TAG, "export() ${eventTimer.stop().summary}") + + return outputStream.toByteArray() + } + + fun import(data: ByteArray) { + val eventTimer = EventTimer() + + val stream = ByteArrayInputStream(data) + val frameReader = PlainTextBackupImportStream(stream) + + // Note: Without a transaction, bad imports could lead to lost data. But because we have a transaction, + // writes from other threads are blocked. This is something to think more about. + SignalDatabase.rawDatabase.withinTransaction { + SignalStore.clearAllDataForBackupRestore() + SignalDatabase.recipients.clearAllDataForBackupRestore() + SignalDatabase.distributionLists.clearAllDataForBackupRestore() + SignalDatabase.threads.clearAllDataForBackupRestore() + SignalDatabase.messages.clearAllDataForBackupRestore() + + val backupState = BackupState() + val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState) + + for (frame in frameReader) { + when { + frame.account != null -> { + AccountDataProcessor.import(frame.account) + eventTimer.emit("account") + } + + frame.recipient != null -> { + RecipientBackupProcessor.import(frame.recipient, backupState) + eventTimer.emit("recipient") + } + + frame.chat != null -> { + ChatBackupProcessor.import(frame.chat, backupState) + eventTimer.emit("chat") + } + + frame.chatItem != null -> { + chatItemInserter.insert(frame.chatItem) + eventTimer.emit("chatItem") + // TODO if there's stuff in the stream after chatItems, we need to flush the inserter before going to the next phase + } + + else -> Log.w(TAG, "Unrecognized frame") + } + } + + if (chatItemInserter.flush()) { + eventTimer.emit("chatItem") + } + + backupState.chatIdToLocalThreadId.values.forEach { + SignalDatabase.threads.update(it, unarchive = false, allowDeletion = false) + } + } + + Log.d(TAG, "import() ${eventTimer.stop().summary}") + } +} + +class BackupState { + val backupToLocalRecipientId = HashMap() + val chatIdToLocalThreadId = HashMap() + val chatIdToLocalRecipientId = HashMap() + val chatIdToBackupRecipientId = HashMap() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt new file mode 100644 index 0000000000..49a29c11f7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemExportIterator.kt @@ -0,0 +1,349 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import android.database.Cursor +import org.signal.core.util.logging.Log +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.requireString +import org.thoughtcrime.securesms.backup.v2.proto.ChatItem +import org.thoughtcrime.securesms.backup.v2.proto.Quote +import org.thoughtcrime.securesms.backup.v2.proto.Reaction +import org.thoughtcrime.securesms.backup.v2.proto.RemoteDeletedMessage +import org.thoughtcrime.securesms.backup.v2.proto.SendStatus +import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage +import org.thoughtcrime.securesms.backup.v2.proto.Text +import org.thoughtcrime.securesms.database.GroupReceiptTable +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.MessageTypes +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet +import org.thoughtcrime.securesms.database.documents.NetworkFailureSet +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.util.JsonUtils +import java.io.Closeable +import java.io.IOException +import java.util.LinkedList +import java.util.Queue +import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange + +/** + * An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions, + * attachments, etc), this will populate items in batches, doing bulk lookups to improve throughput. We keep these in a buffer + * and only do more queries when the buffer is empty. + * + * All of this complexity is hidden from the user -- they just get a normal iterator interface. + */ +class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int) : Iterator, Closeable { + + companion object { + private val TAG = Log.tag(ChatItemExportIterator::class.java) + + const val COLUMN_BASE_TYPE = "base_type" + } + + /** + * A queue of already-parsed ChatItems. Processing in batches means that we read ahead in the cursor and put + * the pending items here. + */ + private val buffer: Queue = LinkedList() + + override fun hasNext(): Boolean { + return buffer.isNotEmpty() || (cursor.count > 0 && !cursor.isLast && !cursor.isAfterLast) + } + + override fun next(): ChatItem { + if (buffer.isNotEmpty()) { + return buffer.remove() + } + + val records: LinkedHashMap = linkedMapOf() + + for (i in 0 until batchSize) { + if (cursor.moveToNext()) { + val record = cursor.toBackupMessageRecord() + records[record.id] = record + } else { + break + } + } + + val reactionsById: Map> = SignalDatabase.reactions.getReactionsForMessages(records.keys) + val groupReceiptsById: Map> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys) + + for ((id, record) in records) { + val builder = record.toBasicChatItemBuilder(groupReceiptsById[id]) + + when { + record.remoteDeleted -> builder.remoteDeletedMessage = RemoteDeletedMessage() + else -> builder.standardMessage = record.toTextMessage(reactionsById[id]) + } + + buffer += builder.build() + } + + return if (buffer.isNotEmpty()) { + buffer.remove() + } else { + throw NoSuchElementException() + } + } + + override fun close() { + cursor.close() + } + + private fun BackupMessageRecord.toBasicChatItemBuilder(groupReceipts: List?): ChatItem.Builder { + val record = this + + return ChatItem.Builder().apply { + chatId = record.threadId + authorId = record.fromRecipientId + dateSent = record.dateSent + dateReceived = record.dateReceived + expireStart = if (record.expireStarted > 0) record.expireStarted else null + expiresIn = if (record.expiresIn > 0) record.expiresIn else null + revisions = emptyList() + sms = !MessageTypes.isSecureType(record.type) + + if (MessageTypes.isOutgoingMessageType(record.type)) { + outgoing = ChatItem.OutgoingMessageDetails( + sendStatus = record.toBackupSendStatus(groupReceipts) + ) + } else { + incoming = ChatItem.IncomingMessageDetails( + dateServerSent = record.dateServer, + sealedSender = record.sealedSender, + read = record.read + ) + } + } + } + + private fun BackupMessageRecord.toTextMessage(reactionRecords: List?): StandardMessage { + return StandardMessage( + quote = this.toQuote(), + text = Text( + body = this.body!!, + bodyRanges = this.bodyRanges?.toBackupBodyRanges() ?: emptyList() + ), + linkPreview = null, + longText = null, + reactions = reactionRecords.toBackupReactions() + ) + } + + private fun BackupMessageRecord.toQuote(): Quote? { + return if (this.quoteTargetSentTimestamp > 0) { + // TODO Attachments! + val type = QuoteModel.Type.fromCode(this.quoteType) + Quote( + targetSentTimestamp = this.quoteTargetSentTimestamp, + authorId = this.quoteAuthor, + text = this.quoteBody, + originalMessageMissing = this.quoteMissing, + bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(), + type = when (type) { + QuoteModel.Type.NORMAL -> Quote.Type.NORMAL + QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFTBADGE + } + ) + } else { + null + } + } + + private fun ByteArray.toBackupBodyRanges(): List { + val decoded: BodyRangeList = try { + BodyRangeList.ADAPTER.decode(this) + } catch (e: IOException) { + Log.w(TAG, "Failed to decode BodyRangeList!") + return emptyList() + } + + return decoded.ranges.map { + BackupBodyRange( + start = it.start, + length = it.length, + mentionAci = it.mentionUuid, + style = it.style?.toBackupBodyRangeStyle() + ) + } + } + + private fun BodyRangeList.BodyRange.Style.toBackupBodyRangeStyle(): BackupBodyRange.Style { + return when (this) { + BodyRangeList.BodyRange.Style.BOLD -> BackupBodyRange.Style.BOLD + BodyRangeList.BodyRange.Style.ITALIC -> BackupBodyRange.Style.ITALIC + BodyRangeList.BodyRange.Style.STRIKETHROUGH -> BackupBodyRange.Style.STRIKETHROUGH + BodyRangeList.BodyRange.Style.MONOSPACE -> BackupBodyRange.Style.MONOSPACE + BodyRangeList.BodyRange.Style.SPOILER -> BackupBodyRange.Style.SPOILER + } + } + + private fun List?.toBackupReactions(): List { + return this + ?.map { + Reaction( + emoji = it.emoji, + authorId = it.author.toLong(), + sentTimestamp = it.dateSent, + receivedTimestamp = it.dateReceived + ) + } ?: emptyList() + } + + private fun BackupMessageRecord.toBackupSendStatus(groupReceipts: List?): List { + if (!MessageTypes.isOutgoingMessageType(this.type)) { + return emptyList() + } + + if (!groupReceipts.isNullOrEmpty()) { + return groupReceipts.toBackupSendStatus(this.networkFailureRecipientIds, this.identityMismatchRecipientIds) + } + + val status: SendStatus.Status = when { + this.viewedReceiptCount > 0 -> SendStatus.Status.VIEWED + this.readReceiptCount > 0 -> SendStatus.Status.READ + this.deliveryReceiptCount > 0 -> SendStatus.Status.DELIVERED + this.baseType == MessageTypes.BASE_SENT_TYPE -> SendStatus.Status.SENT + MessageTypes.isFailedMessageType(this.type) -> SendStatus.Status.FAILED + else -> SendStatus.Status.PENDING + } + + return listOf( + SendStatus( + recipientId = this.toRecipientId, + deliveryStatus = status, + timestamp = this.receiptTimestamp, + sealedSender = this.sealedSender, + networkFailure = this.networkFailureRecipientIds.contains(this.toRecipientId), + identityKeyMismatch = this.identityMismatchRecipientIds.contains(this.toRecipientId) + ) + ) + } + + private fun List.toBackupSendStatus(networkFailureRecipientIds: Set, identityMismatchRecipientIds: Set): List { + return this.map { + SendStatus( + recipientId = it.recipientId.toLong(), + deliveryStatus = it.status.toBackupDeliveryStatus(), + sealedSender = it.isUnidentified, + timestamp = it.timestamp, + networkFailure = networkFailureRecipientIds.contains(it.recipientId.toLong()), + identityKeyMismatch = identityMismatchRecipientIds.contains(it.recipientId.toLong()) + ) + } + } + + private fun Int.toBackupDeliveryStatus(): SendStatus.Status { + return when (this) { + GroupReceiptTable.STATUS_UNDELIVERED -> SendStatus.Status.PENDING + GroupReceiptTable.STATUS_DELIVERED -> SendStatus.Status.DELIVERED + GroupReceiptTable.STATUS_READ -> SendStatus.Status.READ + GroupReceiptTable.STATUS_VIEWED -> SendStatus.Status.VIEWED + GroupReceiptTable.STATUS_SKIPPED -> SendStatus.Status.SKIPPED + else -> SendStatus.Status.SKIPPED + } + } + + private fun String?.parseNetworkFailures(): Set { + if (this.isNullOrBlank()) { + return emptySet() + } + + return try { + JsonUtils.fromJson(this, NetworkFailureSet::class.java).items.map { it.recipientId.toLong() }.toSet() + } catch (e: IOException) { + emptySet() + } + } + + private fun String?.parseIdentityMismatches(): Set { + if (this.isNullOrBlank()) { + return emptySet() + } + + return try { + JsonUtils.fromJson(this, IdentityKeyMismatchSet::class.java).items.map { it.recipientId.toLong() }.toSet() + } catch (e: IOException) { + emptySet() + } + } + + private fun Cursor.toBackupMessageRecord(): BackupMessageRecord { + return BackupMessageRecord( + id = this.requireLong(MessageTable.ID), + dateSent = this.requireLong(MessageTable.DATE_SENT), + dateReceived = this.requireLong(MessageTable.DATE_RECEIVED), + dateServer = this.requireLong(MessageTable.DATE_SERVER), + type = this.requireLong(MessageTable.TYPE), + threadId = this.requireLong(MessageTable.THREAD_ID), + body = this.requireString(MessageTable.BODY), + bodyRanges = this.requireBlob(MessageTable.MESSAGE_RANGES), + fromRecipientId = this.requireLong(MessageTable.FROM_RECIPIENT_ID), + toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID), + expiresIn = this.requireLong(MessageTable.EXPIRES_IN), + expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED), + remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED), + sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED), + quoteTargetSentTimestamp = this.requireLong(MessageTable.QUOTE_ID), + quoteAuthor = this.requireLong(MessageTable.QUOTE_AUTHOR), + quoteBody = this.requireString(MessageTable.QUOTE_BODY), + quoteMissing = this.requireBoolean(MessageTable.QUOTE_MISSING), + quoteBodyRanges = this.requireBlob(MessageTable.QUOTE_BODY_RANGES), + quoteType = this.requireInt(MessageTable.QUOTE_TYPE), + originalMessageId = this.requireLong(MessageTable.ORIGINAL_MESSAGE_ID), + latestRevisionId = this.requireLong(MessageTable.LATEST_REVISION_ID), + deliveryReceiptCount = this.requireInt(MessageTable.DELIVERY_RECEIPT_COUNT), + viewedReceiptCount = this.requireInt(MessageTable.VIEWED_RECEIPT_COUNT), + readReceiptCount = this.requireInt(MessageTable.READ_RECEIPT_COUNT), + read = this.requireBoolean(MessageTable.READ), + receiptTimestamp = this.requireLong(MessageTable.RECEIPT_TIMESTAMP), + networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(), + identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(), + baseType = this.requireLong(COLUMN_BASE_TYPE) + ) + } + + private class BackupMessageRecord( + val id: Long, + val dateSent: Long, + val dateReceived: Long, + val dateServer: Long, + val type: Long, + val threadId: Long, + val body: String?, + val bodyRanges: ByteArray?, + val fromRecipientId: Long, + val toRecipientId: Long, + val expiresIn: Long, + val expireStarted: Long, + val remoteDeleted: Boolean, + val sealedSender: Boolean, + val quoteTargetSentTimestamp: Long, + val quoteAuthor: Long, + val quoteBody: String?, + val quoteMissing: Boolean, + val quoteBodyRanges: ByteArray?, + val quoteType: Int, + val originalMessageId: Long, + val latestRevisionId: Long, + val deliveryReceiptCount: Int, + val readReceiptCount: Int, + val viewedReceiptCount: Int, + val receiptTimestamp: Long, + val read: Boolean, + val networkFailureRecipientIds: Set, + val identityMismatchRecipientIds: Set, + val baseType: Long + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt new file mode 100644 index 0000000000..1253e1d1c9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -0,0 +1,402 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import android.content.ContentValues +import androidx.core.content.contentValuesOf +import org.signal.core.util.SqlUtil +import org.signal.core.util.logging.Log +import org.signal.core.util.toInt +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.proto.BodyRange +import org.thoughtcrime.securesms.backup.v2.proto.ChatItem +import org.thoughtcrime.securesms.backup.v2.proto.Quote +import org.thoughtcrime.securesms.backup.v2.proto.Reaction +import org.thoughtcrime.securesms.backup.v2.proto.SendStatus +import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage +import org.thoughtcrime.securesms.database.GroupReceiptTable +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.MessageTypes +import org.thoughtcrime.securesms.database.ReactionTable +import org.thoughtcrime.securesms.database.SQLiteDatabase +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.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.JsonUtils + +/** + * An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them + * for fast throughput. + */ +class ChatItemImportInserter( + private val db: SQLiteDatabase, + private val backupState: BackupState, + private val batchSize: Int +) { + companion object { + private val TAG = Log.tag(ChatItemImportInserter::class.java) + + private val MESSAGE_COLUMNS = arrayOf( + MessageTable.DATE_SENT, + MessageTable.DATE_RECEIVED, + MessageTable.DATE_SERVER, + MessageTable.TYPE, + MessageTable.THREAD_ID, + MessageTable.READ, + MessageTable.BODY, + MessageTable.FROM_RECIPIENT_ID, + MessageTable.TO_RECIPIENT_ID, + MessageTable.DELIVERY_RECEIPT_COUNT, + MessageTable.READ_RECEIPT_COUNT, + MessageTable.VIEWED_RECEIPT_COUNT, + MessageTable.MISMATCHED_IDENTITIES, + MessageTable.EXPIRES_IN, + MessageTable.EXPIRE_STARTED, + MessageTable.UNIDENTIFIED, + MessageTable.REMOTE_DELETED, + MessageTable.REMOTE_DELETED, + MessageTable.NETWORK_FAILURES, + MessageTable.QUOTE_ID, + MessageTable.QUOTE_AUTHOR, + MessageTable.QUOTE_BODY, + MessageTable.QUOTE_MISSING, + MessageTable.QUOTE_BODY_RANGES, + MessageTable.QUOTE_TYPE, + MessageTable.SHARED_CONTACTS, + MessageTable.LINK_PREVIEWS, + MessageTable.MESSAGE_RANGES, + MessageTable.VIEW_ONCE + ) + + private val REACTION_COLUMNS = arrayOf( + ReactionTable.MESSAGE_ID, + ReactionTable.AUTHOR_ID, + ReactionTable.EMOJI, + ReactionTable.DATE_SENT, + ReactionTable.DATE_RECEIVED + ) + + private val GROUP_RECEIPT_COLUMNS = arrayOf( + GroupReceiptTable.MMS_ID, + GroupReceiptTable.RECIPIENT_ID, + GroupReceiptTable.STATUS, + GroupReceiptTable.TIMESTAMP, + GroupReceiptTable.UNIDENTIFIED + ) + } + + private val selfId = Recipient.self().id + private val buffer: Buffer = Buffer() + private var messageId: Long = SqlUtil.getNextAutoIncrementId(db, MessageTable.TABLE_NAME) + + /** + * Indicate that you want to insert the [ChatItem] into the database. + * If this item causes the buffer to hit the batch size, then a batch of items will actually be inserted. + */ + fun insert(chatItem: ChatItem) { + val fromLocalRecipientId: RecipientId? = backupState.backupToLocalRecipientId[chatItem.authorId] + if (fromLocalRecipientId == null) { + Log.w(TAG, "[insert] Could not find a local recipient for backup recipient ID ${chatItem.authorId}! Skipping.") + return + } + + val chatLocalRecipientId: RecipientId? = backupState.chatIdToLocalRecipientId[chatItem.chatId] + if (chatLocalRecipientId == null) { + Log.w(TAG, "[insert] Could not find a local recipient for chatId ${chatItem.chatId}! Skipping.") + return + } + + val localThreadId: Long? = backupState.chatIdToLocalThreadId[chatItem.chatId] + if (localThreadId == null) { + Log.w(TAG, "[insert] Could not find a local threadId for backup chatId ${chatItem.chatId}! Skipping.") + return + } + + val chatBackupRecipientId: Long? = backupState.chatIdToBackupRecipientId[chatItem.chatId] + if (chatBackupRecipientId == null) { + Log.w(TAG, "[insert] Could not find a backup recipientId for backup chatId ${chatItem.chatId}! Skipping.") + return + } + + buffer.messages += chatItem.toMessageContentValues(fromLocalRecipientId, chatLocalRecipientId, localThreadId) + buffer.reactions += chatItem.toReactionContentValues(messageId) + buffer.groupReceipts += chatItem.toGroupReceiptContentValues(messageId, chatBackupRecipientId) + + messageId++ + + if (buffer.size >= batchSize) { + flush() + } + } + + /** Returns true if something was written to the db, otherwise false. */ + fun flush(): Boolean { + if (buffer.size == 0) { + return false + } + + SqlUtil.buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages).forEach { + db.execSQL(it.where, it.whereArgs) + } + + SqlUtil.buildBulkInsert(ReactionTable.TABLE_NAME, REACTION_COLUMNS, buffer.reactions).forEach { + db.execSQL(it.where, it.whereArgs) + } + + SqlUtil.buildBulkInsert(GroupReceiptTable.TABLE_NAME, GROUP_RECEIPT_COLUMNS, buffer.groupReceipts).forEach { + db.execSQL(it.where, it.whereArgs) + } + + messageId = SqlUtil.getNextAutoIncrementId(db, MessageTable.TABLE_NAME) + + return true + } + + private fun ChatItem.toMessageContentValues(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): ContentValues { + val contentValues = ContentValues() + + contentValues.put(MessageTable.TYPE, this.getMessageType()) + contentValues.put(MessageTable.DATE_SENT, this.dateSent) + contentValues.put(MessageTable.DATE_SERVER, this.incoming?.dateServerSent ?: -1) + contentValues.put(MessageTable.FROM_RECIPIENT_ID, fromRecipientId.serialize()) + contentValues.put(MessageTable.TO_RECIPIENT_ID, (if (this.outgoing != null) chatRecipientId else selfId).serialize()) + contentValues.put(MessageTable.THREAD_ID, threadId) + contentValues.put(MessageTable.DATE_RECEIVED, this.dateReceived) + contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOf { it.timestamp } ?: 0) + contentValues.putNull(MessageTable.LATEST_REVISION_ID) + contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID) + contentValues.put(MessageTable.REVISION_NUMBER, 0) + contentValues.put(MessageTable.EXPIRES_IN, this.expiresIn ?: 0) + contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStart ?: 0) + + if (this.outgoing != null) { + val viewReceiptCount = this.outgoing.sendStatus.count { it.deliveryStatus == SendStatus.Status.VIEWED } + val readReceiptCount = Integer.max(viewReceiptCount, this.outgoing.sendStatus.count { it.deliveryStatus == SendStatus.Status.READ }) + val deliveryReceiptCount = Integer.max(readReceiptCount, this.outgoing.sendStatus.count { it.deliveryStatus == SendStatus.Status.DELIVERED }) + + contentValues.put(MessageTable.VIEWED_RECEIPT_COUNT, viewReceiptCount) + contentValues.put(MessageTable.READ_RECEIPT_COUNT, readReceiptCount) + contentValues.put(MessageTable.DELIVERY_RECEIPT_COUNT, deliveryReceiptCount) + contentValues.put(MessageTable.UNIDENTIFIED, this.outgoing.sendStatus.count { it.sealedSender }) + contentValues.put(MessageTable.READ, 1) + + contentValues.addNetworkFailures(this, backupState) + contentValues.addIdentityKeyMismatches(this, backupState) + } else { + contentValues.put(MessageTable.VIEWED_RECEIPT_COUNT, 0) + contentValues.put(MessageTable.READ_RECEIPT_COUNT, 0) + contentValues.put(MessageTable.DELIVERY_RECEIPT_COUNT, 0) + contentValues.put(MessageTable.UNIDENTIFIED, this.incoming?.sealedSender?.toInt() ?: 0) + contentValues.put(MessageTable.READ, this.incoming?.read?.toInt() ?: 0) + } + + contentValues.put(MessageTable.QUOTE_ID, 0) + contentValues.put(MessageTable.QUOTE_AUTHOR, 0) + contentValues.put(MessageTable.QUOTE_MISSING, 0) + contentValues.put(MessageTable.QUOTE_TYPE, 0) + contentValues.put(MessageTable.VIEW_ONCE, 0) + contentValues.put(MessageTable.REMOTE_DELETED, 0) + + when { + this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage) + this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1) + } + + return contentValues + } + + private fun ChatItem.toReactionContentValues(messageId: Long): List { + val reactions: List = when { + this.standardMessage != null -> this.standardMessage.reactions + this.contactMessage != null -> this.contactMessage.reactions + this.voiceMessage != null -> this.voiceMessage.reactions + this.stickerMessage != null -> this.stickerMessage.reactions + else -> emptyList() + } + + return reactions + .mapNotNull { + val authorId: Long? = backupState.backupToLocalRecipientId[it.authorId]?.toLong() + + if (authorId != null) { + contentValuesOf( + ReactionTable.MESSAGE_ID to messageId, + ReactionTable.AUTHOR_ID to authorId, + ReactionTable.DATE_SENT to it.sentTimestamp, + ReactionTable.DATE_RECEIVED to it.receivedTimestamp, + ReactionTable.EMOJI to it.emoji + ) + } else { + Log.w(TAG, "[Reaction] Could not find a local recipient for backup recipient ID ${it.authorId}! Skipping.") + null + } + } + } + + private fun ChatItem.toGroupReceiptContentValues(messageId: Long, chatBackupRecipientId: Long): List { + if (this.outgoing == null) { + return emptyList() + } + + // TODO This seems like an indirect/bad way to detect if this is a 1:1 or group convo + if (this.outgoing.sendStatus.size == 1 && this.outgoing.sendStatus[0].recipientId == chatBackupRecipientId) { + return emptyList() + } + + return this.outgoing.sendStatus.mapNotNull { sendStatus -> + val recipientId = backupState.backupToLocalRecipientId[sendStatus.recipientId] + + if (recipientId != null) { + contentValuesOf( + GroupReceiptTable.MMS_ID to messageId, + GroupReceiptTable.RECIPIENT_ID to recipientId.serialize(), + GroupReceiptTable.STATUS to sendStatus.deliveryStatus.toLocalSendStatus(), + GroupReceiptTable.TIMESTAMP to sendStatus.timestamp, + GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender + ) + } else { + Log.w(TAG, "[GroupReceipts] Could not find a local recipient for backup recipient ID ${sendStatus.recipientId}! Skipping.") + null + } + } + } + + private fun ChatItem.getMessageType(): Long { + var type: Long = if (this.outgoing != null) { + if (this.outgoing.sendStatus.count { it.identityKeyMismatch } > 0) { + MessageTypes.BASE_SENT_FAILED_TYPE + } else if (this.outgoing.sendStatus.count { it.networkFailure } > 0) { + MessageTypes.BASE_SENDING_TYPE + } else { + MessageTypes.BASE_SENT_TYPE + } + } else { + MessageTypes.BASE_INBOX_TYPE + } + + if (!this.sms) { + type = type or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT + } + + return type + } + + private fun ContentValues.addStandardMessage(standardMessage: StandardMessage) { + if (standardMessage.text != null) { + this.put(MessageTable.BODY, standardMessage.text.body) + + if (standardMessage.text.bodyRanges.isNotEmpty()) { + this.put(MessageTable.MESSAGE_RANGES, standardMessage.text.bodyRanges.toLocalBodyRanges()?.encode() as ByteArray?) + } + } + + if (standardMessage.quote != null) { + this.addQuote(standardMessage.quote) + } + } + + private fun ContentValues.addQuote(quote: Quote) { + this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp) + this.put(MessageTable.QUOTE_AUTHOR, backupState.backupToLocalRecipientId[quote.authorId]!!.serialize()) + this.put(MessageTable.QUOTE_BODY, quote.text) + this.put(MessageTable.QUOTE_TYPE, quote.type.toLocalQuoteType()) + this.put(MessageTable.QUOTE_BODY_RANGES, quote.bodyRanges.toLocalBodyRanges()?.encode()) + // TODO quote attachments + this.put(MessageTable.QUOTE_MISSING, quote.originalMessageMissing.toInt()) + } + + private fun Quote.Type.toLocalQuoteType(): Int { + return when (this) { + Quote.Type.UNKNOWN -> QuoteModel.Type.NORMAL.code + Quote.Type.NORMAL -> QuoteModel.Type.NORMAL.code + Quote.Type.GIFTBADGE -> QuoteModel.Type.GIFT_BADGE.code + } + } + + private fun ContentValues.addNetworkFailures(chatItem: ChatItem, backupState: BackupState) { + if (chatItem.outgoing == null) { + return + } + + val networkFailures = chatItem.outgoing.sendStatus + .filter { status -> status.networkFailure } + .mapNotNull { status -> backupState.backupToLocalRecipientId[status.recipientId] } + .map { recipientId -> NetworkFailure(recipientId) } + .toSet() + + if (networkFailures.isNotEmpty()) { + this.put(MessageTable.NETWORK_FAILURES, JsonUtils.toJson(NetworkFailureSet(networkFailures))) + } + } + + private fun ContentValues.addIdentityKeyMismatches(chatItem: ChatItem, backupState: BackupState) { + if (chatItem.outgoing == null) { + return + } + + val mismatches = chatItem.outgoing.sendStatus + .filter { status -> status.identityKeyMismatch } + .mapNotNull { status -> backupState.backupToLocalRecipientId[status.recipientId] } + .map { recipientId -> IdentityKeyMismatch(recipientId, null) } // TODO We probably want the actual identity key in this status situation? + .toSet() + + if (mismatches.isNotEmpty()) { + this.put(MessageTable.MISMATCHED_IDENTITIES, JsonUtils.toJson(IdentityKeyMismatchSet(mismatches))) + } + } + + private fun List.toLocalBodyRanges(): BodyRangeList? { + if (this.isEmpty()) { + return null + } + + return BodyRangeList( + ranges = this.map { bodyRange -> + BodyRangeList.BodyRange( + mentionUuid = bodyRange.mentionAci, + style = bodyRange.style?.let { + when (bodyRange.style) { + BodyRange.Style.BOLD -> BodyRangeList.BodyRange.Style.BOLD + BodyRange.Style.ITALIC -> BodyRangeList.BodyRange.Style.ITALIC + BodyRange.Style.MONOSPACE -> BodyRangeList.BodyRange.Style.MONOSPACE + BodyRange.Style.SPOILER -> BodyRangeList.BodyRange.Style.SPOILER + BodyRange.Style.STRIKETHROUGH -> BodyRangeList.BodyRange.Style.STRIKETHROUGH + else -> null + } + }, + start = bodyRange.start ?: 0, + length = bodyRange.length ?: 0 + ) + } + ) + } + + private fun SendStatus.Status.toLocalSendStatus(): Int { + return when (this) { + SendStatus.Status.FAILED -> GroupReceiptTable.STATUS_UNKNOWN + SendStatus.Status.PENDING -> GroupReceiptTable.STATUS_UNDELIVERED + SendStatus.Status.SENT -> GroupReceiptTable.STATUS_UNDELIVERED + SendStatus.Status.DELIVERED -> GroupReceiptTable.STATUS_DELIVERED + SendStatus.Status.READ -> GroupReceiptTable.STATUS_READ + SendStatus.Status.VIEWED -> GroupReceiptTable.STATUS_VIEWED + SendStatus.Status.SKIPPED -> GroupReceiptTable.STATUS_SKIPPED + } + } + + private class Buffer( + val messages: MutableList = mutableListOf(), + val reactions: MutableList = mutableListOf(), + val groupReceipts: MutableList = mutableListOf() + ) { + val size: Int + get() = listOf(messages.size, reactions.size, groupReceipts.size).max() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt new file mode 100644 index 0000000000..f132bfd34d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt @@ -0,0 +1,115 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import okio.ByteString.Companion.toByteString +import org.signal.core.util.CursorUtil +import org.signal.core.util.delete +import org.signal.core.util.logging.Log +import org.signal.core.util.readToList +import org.signal.core.util.requireLong +import org.signal.core.util.requireNonNullString +import org.signal.core.util.requireObject +import org.signal.core.util.select +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.database.DistributionListTables +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode +import org.thoughtcrime.securesms.database.model.DistributionListRecord +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.DistributionId +import org.whispersystems.signalservice.api.util.UuidUtil +import org.whispersystems.signalservice.api.util.toByteArray +import org.thoughtcrime.securesms.backup.v2.proto.DistributionList as BackupDistributionList + +private val TAG = Log.tag(DistributionListTables::class.java) + +fun DistributionListTables.getAllForBackup(): List { + val records = readableDatabase + .select() + .from(DistributionListTables.ListTable.TABLE_NAME) + .run() + .readToList { cursor -> + val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID)) + val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer) + + DistributionListRecord( + id = id, + name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME), + distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)), + allowsReplies = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.ALLOWS_REPLIES), + rawMembers = getRawMembers(id, privacyMode), + members = getMembers(id), + deletedAtTimestamp = 0L, + isUnknown = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.IS_UNKNOWN), + privacyMode = privacyMode + ) + } + + return records + .map { record -> + BackupRecipient( + distributionList = BackupDistributionList( + name = record.name, + distributionId = record.distributionId.asUuid().toByteArray().toByteString(), + allowReplies = record.allowsReplies, + deletionTimestamp = record.deletedAtTimestamp, + isUnknown = record.isUnknown, + privacyMode = record.privacyMode.toBackupPrivacyMode(), + memberRecipientIds = record.members.map { it.toLong() } + ) + ) + } +} + +fun DistributionListTables.restoreFromBackup(dlist: BackupDistributionList, backupState: BackupState): RecipientId { + val members: List = dlist.memberRecipientIds + .mapNotNull { backupState.backupToLocalRecipientId[it] } + + if (members.size != dlist.memberRecipientIds.size) { + Log.w(TAG, "Couldn't find some member recipients! Missing backup recipientIds: ${dlist.memberRecipientIds.toSet() - members.toSet()}") + } + + val dlistId = this.createList( + name = dlist.name, + members = members, + distributionId = DistributionId.from(UuidUtil.fromByteString(dlist.distributionId)), + allowsReplies = dlist.allowReplies, + deletionTimestamp = dlist.deletionTimestamp, + storageId = null, + isUnknown = dlist.isUnknown, + privacyMode = dlist.privacyMode.toLocalPrivacyMode() + )!! + + return SignalDatabase.distributionLists.getRecipientId(dlistId)!! +} + +fun DistributionListTables.clearAllDataForBackupRestore() { + writableDatabase + .delete(DistributionListTables.ListTable.TABLE_NAME) + .run() + + writableDatabase + .delete(DistributionListTables.MembershipTable.TABLE_NAME) + .run() +} + +private fun DistributionListPrivacyMode.toBackupPrivacyMode(): BackupDistributionList.PrivacyMode { + return when (this) { + DistributionListPrivacyMode.ONLY_WITH -> BackupDistributionList.PrivacyMode.ONLY_WITH + DistributionListPrivacyMode.ALL -> BackupDistributionList.PrivacyMode.ALL + DistributionListPrivacyMode.ALL_EXCEPT -> BackupDistributionList.PrivacyMode.ALL_EXCEPT + } +} + +private fun BackupDistributionList.PrivacyMode.toLocalPrivacyMode(): DistributionListPrivacyMode { + return when (this) { + BackupDistributionList.PrivacyMode.ONLY_WITH -> DistributionListPrivacyMode.ONLY_WITH + BackupDistributionList.PrivacyMode.ALL -> DistributionListPrivacyMode.ALL + BackupDistributionList.PrivacyMode.ALL_EXCEPT -> DistributionListPrivacyMode.ALL_EXCEPT + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt new file mode 100644 index 0000000000..c1993befb2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/MessageTableBackupExtensions.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import org.signal.core.util.SqlUtil +import org.signal.core.util.logging.Log +import org.signal.core.util.select +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.database.MessageTable +import org.thoughtcrime.securesms.database.MessageTypes + +private val TAG = Log.tag(MessageTable::class.java) +private const val BASE_TYPE = "base_type" + +fun MessageTable.getMessagesForBackup(): ChatItemExportIterator { + val cursor = readableDatabase + .select( + MessageTable.ID, + MessageTable.DATE_SENT, + MessageTable.DATE_RECEIVED, + MessageTable.DATE_SERVER, + MessageTable.TYPE, + MessageTable.THREAD_ID, + MessageTable.BODY, + MessageTable.MESSAGE_RANGES, + MessageTable.FROM_RECIPIENT_ID, + MessageTable.TO_RECIPIENT_ID, + MessageTable.EXPIRES_IN, + MessageTable.EXPIRE_STARTED, + MessageTable.REMOTE_DELETED, + MessageTable.UNIDENTIFIED, + MessageTable.QUOTE_ID, + MessageTable.QUOTE_AUTHOR, + MessageTable.QUOTE_BODY, + MessageTable.QUOTE_MISSING, + MessageTable.QUOTE_BODY_RANGES, + MessageTable.QUOTE_TYPE, + MessageTable.ORIGINAL_MESSAGE_ID, + MessageTable.LATEST_REVISION_ID, + MessageTable.DELIVERY_RECEIPT_COUNT, + MessageTable.READ_RECEIPT_COUNT, + MessageTable.VIEWED_RECEIPT_COUNT, + MessageTable.RECEIPT_TIMESTAMP, + MessageTable.READ, + MessageTable.NETWORK_FAILURES, + MessageTable.MISMATCHED_IDENTITIES, + "${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}" + ) + .from(MessageTable.TABLE_NAME) + .where( + """ + $BASE_TYPE IN ( + ${MessageTypes.BASE_INBOX_TYPE}, + ${MessageTypes.BASE_OUTBOX_TYPE}, + ${MessageTypes.BASE_SENT_TYPE}, + ${MessageTypes.BASE_SENDING_TYPE}, + ${MessageTypes.BASE_SENT_FAILED_TYPE} + ) + """ + ) + .orderBy("${MessageTable.DATE_RECEIVED} ASC") + .run() + + return ChatItemExportIterator(cursor, 100) +} + +fun MessageTable.createChatItemInserter(backupState: BackupState): ChatItemImportInserter { + return ChatItemImportInserter(writableDatabase, backupState, 100) +} + +fun MessageTable.clearAllDataForBackupRestore() { + writableDatabase.delete(MessageTable.TABLE_NAME, null, null) + SqlUtil.resetAutoIncrementValue(writableDatabase, MessageTable.TABLE_NAME) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt new file mode 100644 index 0000000000..700e744bda --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt @@ -0,0 +1,330 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import android.content.ContentValues +import android.database.Cursor +import okio.ByteString.Companion.toByteString +import org.signal.core.util.Base64 +import org.signal.core.util.SqlUtil +import org.signal.core.util.delete +import org.signal.core.util.logging.Log +import org.signal.core.util.nullIfBlank +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireInt +import org.signal.core.util.requireLong +import org.signal.core.util.requireNonNullBlob +import org.signal.core.util.requireString +import org.signal.core.util.select +import org.signal.core.util.toInt +import org.signal.core.util.update +import org.signal.libsignal.zkgroup.InvalidInputException +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.proto.AccountData +import org.thoughtcrime.securesms.backup.v2.proto.Contact +import org.thoughtcrime.securesms.backup.v2.proto.Group +import org.thoughtcrime.securesms.backup.v2.proto.Self +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.RecipientTableCursorUtil +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.push.ServiceId.PNI +import java.io.Closeable + +typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient +typealias BackupGroup = Group + +/** + * Fetches all individual contacts for backups and returns the result as an iterator. + * It's important to note that the iterator still needs to be closed after it's used. + * It's recommended to use `.use` or a try-with-resources pattern. + */ +fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator { + val cursor = readableDatabase + .select( + RecipientTable.ID, + RecipientTable.ACI_COLUMN, + RecipientTable.PNI_COLUMN, + RecipientTable.USERNAME, + RecipientTable.E164, + RecipientTable.BLOCKED, + RecipientTable.HIDDEN, + RecipientTable.REGISTERED, + RecipientTable.UNREGISTERED_TIMESTAMP, + RecipientTable.PROFILE_KEY, + RecipientTable.PROFILE_SHARING, + RecipientTable.PROFILE_GIVEN_NAME, + RecipientTable.PROFILE_FAMILY_NAME, + RecipientTable.PROFILE_JOINED_NAME, + RecipientTable.MUTE_UNTIL, + RecipientTable.EXTRAS + ) + .from(RecipientTable.TABLE_NAME) + .where( + """ + ${RecipientTable.TYPE} = ? AND ( + ${RecipientTable.ACI_COLUMN} NOT NULL OR + ${RecipientTable.PNI_COLUMN} NOT NULL OR + ${RecipientTable.E164} NOT NULL + ) + """, + RecipientTable.RecipientType.INDIVIDUAL.id + ) + .run() + + return BackupContactIterator(cursor, selfId) +} + +fun RecipientTable.getGroupsForBackup(): BackupGroupIterator { + val cursor = readableDatabase + .select( + "${RecipientTable.TABLE_NAME}.${RecipientTable.ID}", + "${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED}", + "${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_SHARING}", + "${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL}", + "${RecipientTable.TABLE_NAME}.${RecipientTable.EXTRAS}", + "${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}", + "${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}" + ) + .from( + """ + ${RecipientTable.TABLE_NAME} + INNER JOIN ${GroupTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID} + """ + ) + .run() + + return BackupGroupIterator(cursor) +} + +/** + * Takes a [BackupRecipient] and writes it into the database. + */ +fun RecipientTable.restoreRecipientFromBackup(recipient: BackupRecipient, backupState: BackupState): RecipientId? { + // TODO Need to handle groups + // TODO Also, should we move this when statement up to mimic the export? Kinda weird that this calls distributionListTable functions + return when { + recipient.contact != null -> restoreContactFromBackup(recipient.contact) + recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState) + recipient.self != null -> Recipient.self().id + else -> { + Log.w(TAG, "Unrecognized recipient type!") + null + } + } +} + +/** + * Given [AccountData], this will insert the necessary data for the local user into the [RecipientTable]. + */ +fun RecipientTable.restoreSelfFromBackup(accountData: AccountData) { + val self = Recipient.trustedPush(ACI.parseOrThrow(accountData.aci.toByteArray()), PNI.parseOrNull(accountData.pni.toByteArray()), accountData.e164.toString()) + + val values = ContentValues().apply { + put(RecipientTable.PROFILE_GIVEN_NAME, accountData.givenName.nullIfBlank()) + put(RecipientTable.PROFILE_FAMILY_NAME, accountData.familyName.nullIfBlank()) + put(RecipientTable.PROFILE_JOINED_NAME, ProfileName.fromParts(accountData.givenName, accountData.familyName).toString().nullIfBlank()) + put(RecipientTable.PROFILE_AVATAR, accountData.avatarUrlPath.nullIfBlank()) + put(RecipientTable.REGISTERED, RecipientTable.RegisteredState.REGISTERED.id) + put(RecipientTable.PROFILE_SHARING, true) + put(RecipientTable.UNREGISTERED_TIMESTAMP, 0) + put(RecipientTable.EXTRAS, RecipientExtras().encode()) + + try { + put(RecipientTable.PROFILE_KEY, Base64.encodeWithPadding(accountData.profileKey.toByteArray()).nullIfBlank()) + } catch (e: InvalidInputException) { + Log.w(TAG, "Missing profile key during restore") + } + + put(RecipientTable.USERNAME, accountData.username) + } + + writableDatabase + .update(RecipientTable.TABLE_NAME) + .values(values) + .where("${RecipientTable.ID} = ?", self.id) + .run() +} + +fun RecipientTable.clearAllDataForBackupRestore() { + writableDatabase.delete(RecipientTable.TABLE_NAME).run() + SqlUtil.resetAutoIncrementValue(writableDatabase, RecipientTable.TABLE_NAME) + + RecipientId.clearCache() + ApplicationDependencies.getRecipientCache().clear() + ApplicationDependencies.getRecipientCache().clearSelf() +} + +private fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId { + val id = getAndPossiblyMergePnpVerified( + aci = ACI.parseOrNull(contact.aci?.toByteArray()), + pni = PNI.parseOrNull(contact.pni?.toByteArray()), + e164 = contact.formattedE164 + ) + + val profileKey = contact.profileKey?.toByteArray() + + writableDatabase + .update(RecipientTable.TABLE_NAME) + .values( + RecipientTable.BLOCKED to contact.blocked, + RecipientTable.HIDDEN to contact.hidden, + RecipientTable.PROFILE_FAMILY_NAME to contact.profileFamilyName.nullIfBlank(), + RecipientTable.PROFILE_GIVEN_NAME to contact.profileGivenName.nullIfBlank(), + RecipientTable.PROFILE_JOINED_NAME to contact.profileJoinedName.nullIfBlank(), + RecipientTable.PROFILE_KEY to if (profileKey == null) null else Base64.encodeWithPadding(profileKey), + RecipientTable.PROFILE_SHARING to contact.profileSharing.toInt(), + RecipientTable.REGISTERED to contact.registered.toLocalRegisteredState().id, + RecipientTable.USERNAME to contact.username, + RecipientTable.UNREGISTERED_TIMESTAMP to contact.unregisteredTimestamp, + RecipientTable.EXTRAS to contact.toLocalExtras().encode() + ) + .where("${RecipientTable.ID} = ?", id) + .run() + + return id +} + +private fun Contact.toLocalExtras(): RecipientExtras { + return RecipientExtras( + hideStory = this.hideStory + ) +} + +/** + * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. + * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. + */ +class BackupContactIterator(private val cursor: Cursor, private val selfId: Long) : Iterator, Closeable { + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): BackupRecipient { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val id = cursor.requireLong(RecipientTable.ID) + if (id == selfId) { + return BackupRecipient( + id = id, + self = Self() + ) + } + + val aci = ACI.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN)) + val pni = PNI.parseOrNull(cursor.requireString(RecipientTable.PNI_COLUMN)) + val registeredState = RecipientTable.RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED)) + val profileKey = cursor.requireString(RecipientTable.PROFILE_KEY) + val extras = RecipientTableCursorUtil.getExtras(cursor) + + return BackupRecipient( + id = id, + contact = Contact( + aci = aci?.toByteArray()?.toByteString(), + pni = pni?.toByteArray()?.toByteString(), + username = cursor.requireString(RecipientTable.USERNAME), + e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong(), + blocked = cursor.requireBoolean(RecipientTable.BLOCKED), + hidden = cursor.requireBoolean(RecipientTable.HIDDEN), + registered = registeredState.toContactRegisteredState(), + unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP), + profileKey = if (profileKey != null) Base64.decode(profileKey).toByteString() else null, + profileSharing = cursor.requireBoolean(RecipientTable.PROFILE_SHARING), + profileGivenName = cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME).nullIfBlank(), + profileFamilyName = cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME).nullIfBlank(), + profileJoinedName = cursor.requireString(RecipientTable.PROFILE_JOINED_NAME).nullIfBlank(), + hideStory = extras?.hideStory() ?: false + ) + ) + } + + override fun close() { + cursor.close() + } +} + +/** + * Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s. + * Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources. + */ +class BackupGroupIterator(private val cursor: Cursor) : Iterator, Closeable { + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): BackupRecipient { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + val extras = RecipientTableCursorUtil.getExtras(cursor) + val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE)) + + return BackupRecipient( + id = cursor.requireLong(RecipientTable.ID), + group = BackupGroup( + masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(), + whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING), + hideStory = extras?.hideStory() ?: false, + storySendMode = showAsStoryState.toGroupStorySendMode() + ) + ) + } + + override fun close() { + cursor.close() + } +} + +private fun String.e164ToLong(): Long? { + val fixed = if (this.startsWith("+")) { + this.substring(1) + } else { + this + } + + return fixed.toLongOrNull() +} + +private fun RecipientTable.RegisteredState.toContactRegisteredState(): Contact.Registered { + return when (this) { + RecipientTable.RegisteredState.REGISTERED -> Contact.Registered.REGISTERED + RecipientTable.RegisteredState.NOT_REGISTERED -> Contact.Registered.NOT_REGISTERED + RecipientTable.RegisteredState.UNKNOWN -> Contact.Registered.UNKNOWN + } +} + +private fun Contact.Registered.toLocalRegisteredState(): RecipientTable.RegisteredState { + return when (this) { + Contact.Registered.REGISTERED -> RecipientTable.RegisteredState.REGISTERED + Contact.Registered.NOT_REGISTERED -> RecipientTable.RegisteredState.NOT_REGISTERED + Contact.Registered.UNKNOWN -> RecipientTable.RegisteredState.UNKNOWN + } +} + +private fun GroupTable.ShowAsStoryState.toGroupStorySendMode(): Group.StorySendMode { + return when (this) { + GroupTable.ShowAsStoryState.ALWAYS -> Group.StorySendMode.ENABLED + GroupTable.ShowAsStoryState.NEVER -> Group.StorySendMode.DISABLED + GroupTable.ShowAsStoryState.IF_ACTIVE -> Group.StorySendMode.DEFAULT + } +} + +private val Contact.formattedE164: String? + get() { + return e164?.let { + PhoneNumberFormatter.get(ApplicationDependencies.getApplication()).format(e164.toString()) + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ThreadTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ThreadTableBackupExtensions.kt new file mode 100644 index 0000000000..76f628ed12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ThreadTableBackupExtensions.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import android.database.Cursor +import org.signal.core.util.SqlUtil +import org.signal.core.util.requireBoolean +import org.signal.core.util.requireLong +import org.signal.core.util.select +import org.thoughtcrime.securesms.backup.v2.proto.Chat +import org.thoughtcrime.securesms.database.ThreadTable +import java.io.Closeable + +fun ThreadTable.getThreadsForBackup(): ChatIterator { + val cursor = readableDatabase + .select( + ThreadTable.ID, + ThreadTable.RECIPIENT_ID, + ThreadTable.ARCHIVED, + ThreadTable.PINNED, + ThreadTable.EXPIRES_IN + ) + .from(ThreadTable.TABLE_NAME) + .run() + + return ChatIterator(cursor) +} + +fun ThreadTable.clearAllDataForBackupRestore() { + writableDatabase.delete(ThreadTable.TABLE_NAME, null, null) + SqlUtil.resetAutoIncrementValue(writableDatabase, ThreadTable.TABLE_NAME) + clearCache() +} + +class ChatIterator(private val cursor: Cursor) : Iterator, Closeable { + override fun hasNext(): Boolean { + return cursor.count > 0 && !cursor.isLast + } + + override fun next(): Chat { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + return Chat( + id = cursor.requireLong(ThreadTable.ID), + recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID), + archived = cursor.requireBoolean(ThreadTable.ARCHIVED), + pinned = cursor.requireBoolean(ThreadTable.PINNED), + expirationTimer = cursor.requireLong(ThreadTable.EXPIRES_IN) + ) + } + + override fun close() { + cursor.close() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt new file mode 100644 index 0000000000..15a5c6420d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/AccountDataProcessor.kt @@ -0,0 +1,176 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.processor + +import okio.ByteString.Companion.EMPTY +import okio.ByteString.Companion.toByteString +import org.thoughtcrime.securesms.backup.v2.database.restoreSelfFromBackup +import org.thoughtcrime.securesms.backup.v2.proto.AccountData +import org.thoughtcrime.securesms.backup.v2.proto.Frame +import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.subscription.Subscriber +import org.thoughtcrime.securesms.util.ProfileUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.push.UsernameLinkComponents +import org.whispersystems.signalservice.api.storage.StorageRecordProtoUtil.defaultAccountRecord +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import org.whispersystems.signalservice.api.util.UuidUtil + +object AccountDataProcessor { + + fun export(emitter: BackupFrameEmitter) { + val context = ApplicationDependencies.getApplication() + + val self = Recipient.self().fresh() + val record = recipients.getRecordForSync(self.id) + + val pniIdentityKey = SignalStore.account().pniIdentityKey + val aciIdentityKey = SignalStore.account().aciIdentityKey + + val subscriber: Subscriber? = SignalStore.donationsValues().getSubscriber() + + emitter.emit( + Frame( + account = AccountData( + aci = SignalStore.account().aci!!.toByteString(), + pni = SignalStore.account().pni!!.toByteString(), + e164 = SignalStore.account().e164!!.toLong(), + pniIdentityPrivateKey = pniIdentityKey.privateKey.serialize().toByteString(), + pniIdentityPublicKey = pniIdentityKey.publicKey.serialize().toByteString(), + aciIdentityPrivateKey = aciIdentityKey.privateKey.serialize().toByteString(), + aciIdentityPublicKey = aciIdentityKey.publicKey.serialize().toByteString(), + profileKey = self.profileKey?.toByteString() ?: EMPTY, + givenName = self.profileName.givenName, + familyName = self.profileName.familyName, + avatarUrlPath = self.profileAvatar ?: "", + subscriptionManuallyCancelled = SignalStore.donationsValues().isUserManuallyCancelled(), + username = SignalStore.account().username, + subscriberId = subscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId, + subscriberCurrencyCode = subscriber?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode, + accountSettings = AccountData.AccountSettings( + storyViewReceiptsEnabled = SignalStore.storyValues().viewedReceiptsEnabled, + hasReadOnboardingStory = SignalStore.storyValues().userHasViewedOnboardingStory || SignalStore.storyValues().userHasReadOnboardingStory, + noteToSelfArchived = record != null && record.syncExtras.isArchived, + noteToSelfMarkedUnread = record != null && record.syncExtras.isForcedUnread, + typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context), + readReceipts = TextSecurePreferences.isReadReceiptsEnabled(context), + sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context), + linkPreviews = SignalStore.settings().isLinkPreviewsEnabled, + unlistedPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isUnlisted, + phoneNumberSharingMode = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode.toBackupPhoneNumberSharingMode(), + preferContactAvatars = SignalStore.settings().isPreferSystemContactPhotos, + universalExpireTimer = SignalStore.settings().universalExpireTimer, + preferredReactionEmoji = SignalStore.emojiValues().reactions, + storiesDisabled = SignalStore.storyValues().isFeatureDisabled, + hasViewedOnboardingStory = SignalStore.storyValues().userHasViewedOnboardingStory, + hasSetMyStoriesPrivacy = SignalStore.storyValues().userHasBeenNotifiedAboutStories, + keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(), + displayBadgesOnProfile = SignalStore.donationsValues().getDisplayBadgesOnProfile(), + hasSeenGroupStoryEducationSheet = SignalStore.storyValues().userHasSeenGroupStoryEducationSheet + ) + ) + ) + ) + } + + fun import(accountData: AccountData) { + SignalStore.account().restoreAciIdentityKeyFromBackup(accountData.aciIdentityPublicKey.toByteArray(), accountData.aciIdentityPrivateKey.toByteArray()) + SignalStore.account().restorePniIdentityKeyFromBackup(accountData.pniIdentityPublicKey.toByteArray(), accountData.pniIdentityPrivateKey.toByteArray()) + + recipients.restoreSelfFromBackup(accountData) + + SignalStore.account().setRegistered(true) + + val context = ApplicationDependencies.getApplication() + + val settings = accountData.accountSettings + + if (settings != null) { + TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts) + TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators) + TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators) + SignalStore.settings().isLinkPreviewsEnabled = settings.linkPreviews + SignalStore.phoneNumberPrivacy().phoneNumberListingMode = if (settings.unlistedPhoneNumber) PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED else PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED + SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode() + SignalStore.settings().isPreferSystemContactPhotos = settings.preferContactAvatars + SignalStore.settings().universalExpireTimer = settings.universalExpireTimer + SignalStore.emojiValues().reactions = settings.preferredReactionEmoji + SignalStore.donationsValues().setDisplayBadgesOnProfile(settings.displayBadgesOnProfile) + SignalStore.settings().setKeepMutedChatsArchived(settings.keepMutedChatsArchived) + SignalStore.storyValues().userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy + SignalStore.storyValues().userHasViewedOnboardingStory = settings.hasViewedOnboardingStory + SignalStore.storyValues().isFeatureDisabled = settings.storiesDisabled + SignalStore.storyValues().userHasReadOnboardingStory = settings.hasReadOnboardingStory + SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet + SignalStore.storyValues().viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts + + if (accountData.subscriptionManuallyCancelled) { + SignalStore.donationsValues().updateLocalStateForManualCancellation() + } else { + SignalStore.donationsValues().clearUserManuallyCancelled() + } + + if (accountData.subscriberId.size > 0) { + val subscriber = Subscriber(SubscriberId.fromBytes(accountData.subscriberId.toByteArray()), accountData.subscriberCurrencyCode) + SignalStore.donationsValues().setSubscriber(subscriber) + } + + if (accountData.avatarUrlPath.isNotEmpty()) { + ApplicationDependencies.getJobManager().add(RetrieveProfileAvatarJob(Recipient.self().fresh(), accountData.avatarUrlPath)) + } + + if (accountData.usernameLink != null) { + SignalStore.account().usernameLink = UsernameLinkComponents( + accountData.usernameLink.entropy.toByteArray(), + UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray()) + ) + SignalStore.misc().usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor() + } + } + + SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() } + + Recipient.self().live().refresh() + } + + private fun PhoneNumberPrivacyValues.PhoneNumberSharingMode.toBackupPhoneNumberSharingMode(): AccountData.PhoneNumberSharingMode { + return when (this) { + PhoneNumberPrivacyValues.PhoneNumberSharingMode.DEFAULT -> AccountData.PhoneNumberSharingMode.EVERYBODY + PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY -> AccountData.PhoneNumberSharingMode.EVERYBODY + PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY -> AccountData.PhoneNumberSharingMode.NOBODY + } + } + + private fun AccountData.PhoneNumberSharingMode.toLocalPhoneNumberMode(): PhoneNumberPrivacyValues.PhoneNumberSharingMode { + return when (this) { + AccountData.PhoneNumberSharingMode.UNKNOWN -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY + AccountData.PhoneNumberSharingMode.EVERYBODY -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY + AccountData.PhoneNumberSharingMode.NOBODY -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY + } + } + + private fun AccountData.UsernameLink.Color?.toLocalUsernameColor(): UsernameQrCodeColorScheme { + return when (this) { + AccountData.UsernameLink.Color.BLUE -> UsernameQrCodeColorScheme.Blue + AccountData.UsernameLink.Color.WHITE -> UsernameQrCodeColorScheme.White + AccountData.UsernameLink.Color.GREY -> UsernameQrCodeColorScheme.Grey + AccountData.UsernameLink.Color.OLIVE -> UsernameQrCodeColorScheme.Tan + AccountData.UsernameLink.Color.GREEN -> UsernameQrCodeColorScheme.Green + AccountData.UsernameLink.Color.ORANGE -> UsernameQrCodeColorScheme.Orange + AccountData.UsernameLink.Color.PINK -> UsernameQrCodeColorScheme.Pink + AccountData.UsernameLink.Color.PURPLE -> UsernameQrCodeColorScheme.Purple + else -> UsernameQrCodeColorScheme.Blue + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt new file mode 100644 index 0000000000..41d7834193 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.processor + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.database.getThreadsForBackup +import org.thoughtcrime.securesms.backup.v2.proto.Chat +import org.thoughtcrime.securesms.backup.v2.proto.Frame +import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.RecipientId +import java.util.Collections + +object ChatBackupProcessor { + val TAG = Log.tag(ChatBackupProcessor::class.java) + + fun export(emitter: BackupFrameEmitter) { + SignalDatabase.threads.getThreadsForBackup().use { reader -> + for (chat in reader) { + emitter.emit(Frame(chat = chat)) + } + } + } + + fun import(chat: Chat, backupState: BackupState) { + // TODO Perf can be improved here by doing a single insert instead of insert + multiple updates + + val recipientId: RecipientId? = backupState.backupToLocalRecipientId[chat.recipientId] + + if (recipientId != null) { + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(org.thoughtcrime.securesms.recipients.Recipient.resolved(recipientId)) + + if (chat.archived) { + SignalDatabase.threads.archiveConversation(threadId) + } + + if (chat.pinned) { + SignalDatabase.threads.pinConversations(Collections.singleton(threadId)) + } + + backupState.chatIdToLocalRecipientId[chat.id] = recipientId + backupState.chatIdToLocalThreadId[chat.id] = threadId + backupState.chatIdToBackupRecipientId[chat.id] = chat.recipientId + } else { + Log.w(TAG, "Recipient doesnt exist with id $recipientId") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt new file mode 100644 index 0000000000..e739fc9a28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.processor + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter +import org.thoughtcrime.securesms.backup.v2.database.createChatItemInserter +import org.thoughtcrime.securesms.backup.v2.database.getMessagesForBackup +import org.thoughtcrime.securesms.backup.v2.proto.Frame +import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter +import org.thoughtcrime.securesms.database.SignalDatabase + +object ChatItemBackupProcessor { + val TAG = Log.tag(ChatItemBackupProcessor::class.java) + + fun export(emitter: BackupFrameEmitter) { + SignalDatabase.messages.getMessagesForBackup().use { chatItems -> + for (chatItem in chatItems) { + emitter.emit(Frame(chatItem = chatItem)) + } + } + } + + fun beginImport(backupState: BackupState): ChatItemImportInserter { + return SignalDatabase.messages.createChatItemInserter(backupState) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt new file mode 100644 index 0000000000..c3f2d26496 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.processor + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup +import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup +import org.thoughtcrime.securesms.backup.v2.database.getGroupsForBackup +import org.thoughtcrime.securesms.backup.v2.database.restoreRecipientFromBackup +import org.thoughtcrime.securesms.backup.v2.proto.Frame +import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.Recipient + +typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient + +object RecipientBackupProcessor { + + val TAG = Log.tag(RecipientBackupProcessor::class.java) + + fun export(emitter: BackupFrameEmitter) { + val selfId = Recipient.self().id.toLong() + + SignalDatabase.recipients.getContactsForBackup(selfId).use { reader -> + for (backupRecipient in reader) { + emitter.emit(Frame(recipient = backupRecipient)) + } + } + + SignalDatabase.recipients.getGroupsForBackup().use { reader -> + for (backupRecipient in reader) { + emitter.emit(Frame(recipient = backupRecipient)) + } + } + + SignalDatabase.distributionLists.getAllForBackup().forEach { + emitter.emit(Frame(recipient = it)) + } + } + + fun import(recipient: BackupRecipient, backupState: BackupState) { + val newId = SignalDatabase.recipients.restoreRecipientFromBackup(recipient, backupState) + if (newId != null) { + backupState.backupToLocalRecipientId[recipient.id] = newId + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupExportStream.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupExportStream.kt new file mode 100644 index 0000000000..227f2dced3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupExportStream.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.stream + +import org.thoughtcrime.securesms.backup.v2.proto.Frame + +interface BackupExportStream { + fun write(frame: Frame) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupFrameEmitter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupFrameEmitter.kt new file mode 100644 index 0000000000..50ae25584f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupFrameEmitter.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.stream + +import org.thoughtcrime.securesms.backup.v2.proto.Frame + +/** + * An interface that lets sub-processors emit [Frame]s as they export data. + */ +fun interface BackupFrameEmitter { + fun emit(frame: Frame) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportStream.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportStream.kt new file mode 100644 index 0000000000..b47df24bc5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportStream.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.stream + +import org.thoughtcrime.securesms.backup.v2.proto.Frame + +interface BackupImportStream { + fun read(): Frame? +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupExportStream.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupExportStream.kt new file mode 100644 index 0000000000..1916ec3eed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupExportStream.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.stream + +import org.signal.core.util.Conversions +import org.thoughtcrime.securesms.backup.v2.proto.Frame +import java.io.IOException +import java.io.OutputStream + +/** + * Writes backup frames to the wrapped stream in plain text. Only for testing! + */ +class PlainTextBackupExportStream(private val outputStream: OutputStream) : BackupExportStream { + + @Throws(IOException::class) + override fun write(frame: Frame) { + val frameBytes: ByteArray = frame.encode() + val lengthBytes: ByteArray = Conversions.intToByteArray(frameBytes.size) + + outputStream.write(lengthBytes) + outputStream.write(frameBytes) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupImportStream.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupImportStream.kt new file mode 100644 index 0000000000..f3f2701a23 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupImportStream.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.stream + +import org.signal.core.util.Conversions +import org.signal.core.util.readNBytesOrThrow +import org.thoughtcrime.securesms.backup.v2.proto.Frame +import java.io.EOFException +import java.io.InputStream + +/** + * Reads a plaintext backup import stream one frame at a time. + */ +class PlainTextBackupImportStream(val inputStream: InputStream) : BackupImportStream, Iterator { + + var next: Frame? = null + + init { + next = read() + } + + override fun hasNext(): Boolean { + return next != null + } + + override fun next(): Frame { + next?.let { out -> + next = read() + return out + } ?: throw NoSuchElementException() + } + + override fun read(): Frame? { + try { + val lengthBytes: ByteArray = inputStream.readNBytesOrThrow(4) + val length = Conversions.byteArrayToInt(lengthBytes) + + val frameBytes: ByteArray = inputStream.readNBytesOrThrow(length) + val frame: Frame = Frame.ADAPTER.decode(frameBytes) + + return frame + } catch (e: EOFException) { + return null + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 999415add5..ec027ed781 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -160,6 +160,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) + clickPref( + title = DSLSettingsText.from("Backup Playground"), + summary = DSLSettingsText.from("Test backup import/export."), + onClick = { + findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalBackupPlaygroundFragment()) + } + ) + switchPref( title = DSLSettingsText.from("'Internal Details' button"), summary = DSLSettingsText.from("Show a button in conversation settings that lets you see more information about a user."), diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt new file mode 100644 index 0000000000..b95b7f776b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.backup + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.tooling.preview.Preview +import androidx.fragment.app.viewModels +import org.signal.core.ui.Buttons +import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupState +import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState +import org.thoughtcrime.securesms.compose.ComposeFragment + +class InternalBackupPlaygroundFragment : ComposeFragment() { + + val viewModel: InternalBackupPlaygroundViewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state + + Screen( + state = state, + onExportClicked = { viewModel.export() }, + onImportClicked = { viewModel.import() } + ) + } +} + +@Composable +fun Screen( + state: ScreenState, + onExportClicked: () -> Unit = {}, + onImportClicked: () -> Unit = {} +) { + Surface { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Buttons.LargePrimary( + onClick = onExportClicked, + enabled = !state.backupState.inProgress + ) { + Text("Export") + } + Buttons.LargeTonal( + onClick = onImportClicked, + enabled = state.backupState == BackupState.EXPORT_DONE + ) { + Text("Import") + } + } + } +} + +@Preview +@Composable +fun PreviewScreen() { + Screen(state = ScreenState(backupState = BackupState.NONE)) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt new file mode 100644 index 0000000000..645cd14bf4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.internal.backup + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.backup.v2.BackupRepository + +class InternalBackupPlaygroundViewModel : ViewModel() { + + var backupData: ByteArray? = null + + val disposables = CompositeDisposable() + + private val _state: MutableState = mutableStateOf(ScreenState(backupState = BackupState.NONE)) + val state: State = _state + + fun export() { + _state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS) + + disposables += Single.fromCallable { BackupRepository.export() } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { data -> + backupData = data + _state.value = _state.value.copy(backupState = BackupState.EXPORT_DONE) + } + } + + fun import() { + backupData?.let { + _state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS) + + disposables += Single.fromCallable { BackupRepository.import(it) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { nothing -> + backupData = null + _state.value = _state.value.copy(backupState = BackupState.NONE) + } + } + } + + override fun onCleared() { + disposables.clear() + } + + data class ScreenState( + val backupState: BackupState + ) + + enum class BackupState(val inProgress: Boolean = false) { + NONE, EXPORT_IN_PROGRESS(true), EXPORT_DONE, IMPORT_IN_PROGRESS(true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java index 31589b2ef6..cb385292c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java @@ -155,7 +155,7 @@ public final class SafetyNumberChangeRepository { IdentityKey newIdentityKey = messageRecord.getIdentityKeyMismatches() .stream() - .filter(mismatch -> mismatch.getRecipientId(context).equals(changedRecipient.getRecipient().getId())) + .filter(mismatch -> mismatch.getRecipientId().equals(changedRecipient.getRecipient().getId())) .map(IdentityKeyMismatch::getIdentityKey) .filter(Objects::nonNull) .findFirst() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseTable.java index 1193485875..25afaa5eb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseTable.java @@ -79,11 +79,11 @@ public abstract class DatabaseTable { this.databaseHelper = databaseHelper; } - protected SQLiteDatabase getReadableDatabase() { + public SQLiteDatabase getReadableDatabase() { return databaseHelper.getSignalReadableDatabase(); } - protected SQLiteDatabase getWritableDatabase() { + public SQLiteDatabase getWritableDatabase() { return databaseHelper.getSignalWritableDatabase(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt index fe234680f5..04fe2c431d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt @@ -106,7 +106,7 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign val LIST_UI_PROJECTION = arrayOf(ID, NAME, RECIPIENT_ID, ALLOWS_REPLIES, IS_UNKNOWN, PRIVACY_MODE, SEARCH_NAME) } - private object MembershipTable { + object MembershipTable { const val TABLE_NAME = "distribution_list_member" const val ID = "_id" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptTable.kt index 80afa68642..ee1bf1fa09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupReceiptTable.kt @@ -2,9 +2,11 @@ package org.thoughtcrime.securesms.database import android.content.ContentValues import android.content.Context +import android.database.Cursor import androidx.core.content.contentValuesOf import org.signal.core.util.SqlUtil import org.signal.core.util.delete +import org.signal.core.util.forEach import org.signal.core.util.readToList import org.signal.core.util.requireBoolean import org.signal.core.util.requireInt @@ -21,9 +23,9 @@ class GroupReceiptTable(context: Context?, databaseHelper: SignalDatabase?) : Da private const val ID = "_id" const val MMS_ID = "mms_id" const val RECIPIENT_ID = "address" - private const val STATUS = "status" - private const val TIMESTAMP = "timestamp" - private const val UNIDENTIFIED = "unidentified" + const val STATUS = "status" + const val TIMESTAMP = "timestamp" + const val UNIDENTIFIED = "unidentified" const val STATUS_UNKNOWN = -1 const val STATUS_UNDELIVERED = 0 const val STATUS_DELIVERED = 1 @@ -127,14 +129,32 @@ class GroupReceiptTable(context: Context?, databaseHelper: SignalDatabase?) : Da .from(TABLE_NAME) .where("$MMS_ID = ?", mmsId) .run() - .readToList { cursor -> - GroupReceiptInfo( - recipientId = RecipientId.from(cursor.requireLong(RECIPIENT_ID)), - status = cursor.requireInt(STATUS), - timestamp = cursor.requireLong(TIMESTAMP), - isUnidentified = cursor.requireBoolean(UNIDENTIFIED) - ) - } + .readToList { it.toGroupReceiptInfo() } + } + + fun getGroupReceiptInfoForMessages(ids: Set): Map> { + if (ids.isEmpty()) { + return emptyMap() + } + + val messageIdsToGroupReceipts: MutableMap> = mutableMapOf() + + val args: List> = ids.map { SqlUtil.buildArgs(it) } + + SqlUtil.buildCustomCollectionQuery("$MMS_ID = ?", args).forEach { query -> + readableDatabase + .select() + .from(TABLE_NAME) + .where(query.where, query.whereArgs) + .run() + .forEach { cursor -> + val messageId = cursor.requireLong(MMS_ID) + val receipts = messageIdsToGroupReceipts.getOrPut(messageId) { mutableListOf() } + receipts += cursor.toGroupReceiptInfo() + } + } + + return messageIdsToGroupReceipts } fun deleteRowsForMessage(mmsId: Long) { @@ -163,6 +183,15 @@ class GroupReceiptTable(context: Context?, databaseHelper: SignalDatabase?) : Da .run() } + private fun Cursor.toGroupReceiptInfo(): GroupReceiptInfo { + return GroupReceiptInfo( + recipientId = RecipientId.from(this.requireLong(RECIPIENT_ID)), + status = this.requireInt(STATUS), + timestamp = this.requireLong(TIMESTAMP), + isUnidentified = this.requireBoolean(UNIDENTIFIED) + ) + } + data class GroupReceiptInfo( val recipientId: RecipientId, val status: Int, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 635841b45d..4a68e67214 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -1770,7 +1770,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat return threads.getOrCreateThreadIdFor(recipient) } - private fun rawQueryWithAttachments(where: String, arguments: Array?, reverse: Boolean = false, limit: Long = 0): Cursor { + fun rawQueryWithAttachments(where: String, arguments: Array?, reverse: Boolean = false, limit: Long = 0): Cursor { return rawQueryWithAttachments(MMS_PROJECTION_WITH_ATTACHMENTS, where, arguments, reverse, limit) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt index 851b0b0a74..0d1b0e5363 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt @@ -22,10 +22,10 @@ class ReactionTable(context: Context, databaseHelper: SignalDatabase) : Database private const val ID = "_id" const val MESSAGE_ID = "message_id" - private const val AUTHOR_ID = "author_id" - private const val EMOJI = "emoji" - private const val DATE_SENT = "date_sent" - private const val DATE_RECEIVED = "date_received" + const val AUTHOR_ID = "author_id" + const val EMOJI = "emoji" + const val DATE_SENT = "date_sent" + const val DATE_RECEIVED = "date_received" @JvmField val CREATE_TABLE = """ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index f3c7b8af5f..6e7629d871 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -17,10 +17,7 @@ import org.signal.core.util.SqlUtil import org.signal.core.util.delete import org.signal.core.util.exists import org.signal.core.util.logging.Log -import org.signal.core.util.optionalBlob -import org.signal.core.util.optionalBoolean -import org.signal.core.util.optionalInt -import org.signal.core.util.optionalLong +import org.signal.core.util.nullIfBlank import org.signal.core.util.optionalString import org.signal.core.util.or import org.signal.core.util.orNull @@ -29,7 +26,6 @@ import org.signal.core.util.readToSet import org.signal.core.util.readToSingleBoolean import org.signal.core.util.readToSingleLong 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 @@ -39,11 +35,9 @@ import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.InvalidKeyException -import org.signal.libsignal.zkgroup.InvalidInputException import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.signal.storageservice.protos.groups.local.DecryptedGroup -import org.thoughtcrime.securesms.badges.Badges import org.thoughtcrime.securesms.badges.Badges.toDatabaseBadge import org.thoughtcrime.securesms.badges.models.Badge import org.thoughtcrime.securesms.color.MaterialColor @@ -58,6 +52,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil import org.thoughtcrime.securesms.database.GroupTable.LegacyGroupInsertException import org.thoughtcrime.securesms.database.GroupTable.ShowAsStoryState import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus +import org.thoughtcrime.securesms.database.RecipientTableCursorUtil.getRecipientExtras import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups import org.thoughtcrime.securesms.database.SignalDatabase.Companion.identities import org.thoughtcrime.securesms.database.SignalDatabase.Companion.runPostSuccessfulTransaction @@ -84,7 +79,6 @@ import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.jobs.RetrieveProfileJob import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.profiles.AvatarHelper import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -93,12 +87,10 @@ import org.thoughtcrime.securesms.storage.StorageRecordUpdate import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.storage.StorageSyncModels import org.thoughtcrime.securesms.util.FeatureFlags -import org.thoughtcrime.securesms.util.GroupUtil import org.thoughtcrime.securesms.util.IdentityUtil import org.thoughtcrime.securesms.util.ProfileUtil import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.wallpaper.ChatWallpaper -import org.thoughtcrime.securesms.wallpaper.ChatWallpaperFactory import org.thoughtcrime.securesms.wallpaper.WallpaperStorage import org.whispersystems.signalservice.api.profiles.SignalServiceProfile import org.whispersystems.signalservice.api.push.ServiceId @@ -113,7 +105,6 @@ import org.whispersystems.signalservice.api.storage.StorageId import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record import java.io.Closeable import java.io.IOException -import java.util.Arrays import java.util.Collections import java.util.LinkedList import java.util.Objects @@ -123,9 +114,9 @@ import kotlin.math.max open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) { - companion object { - private val TAG = Log.tag(RecipientTable::class.java) + val TAG = Log.tag(RecipientTable::class.java) + companion object { private val UNREGISTERED_LIFESPAN: Long = TimeUnit.DAYS.toMillis(30) const val TABLE_NAME = "recipient" @@ -651,6 +642,15 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } + fun getAll(): RecipientIterator { + val cursor = readableDatabase + .select() + .from(TABLE_NAME) + .run() + + return RecipientIterator(context, cursor) + } + /** * Only call once to create initial release channel recipient. */ @@ -704,7 +704,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da readableDatabase.query(TABLE_NAME, RECIPIENT_PROJECTION, query, args, null, null, null).use { cursor -> return if (cursor != null && cursor.moveToNext()) { - getRecord(context, cursor) + RecipientTableCursorUtil.getRecord(context, cursor) } else { findRemappedIdRecord(id) } @@ -1113,7 +1113,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da readableDatabase.query(table, columns, query, args, "$TABLE_NAME.$ID", null, null).use { cursor -> while (cursor != null && cursor.moveToNext()) { - out.add(getRecord(context, cursor)) + out.add(RecipientTableCursorUtil.getRecord(context, cursor)) } } @@ -1709,9 +1709,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da fun setProfileName(id: RecipientId, profileName: ProfileName) { val contentValues = ContentValues(1).apply { - put(PROFILE_GIVEN_NAME, profileName.givenName) - put(PROFILE_FAMILY_NAME, profileName.familyName) - put(PROFILE_JOINED_NAME, profileName.toString()) + put(PROFILE_GIVEN_NAME, profileName.givenName.nullIfBlank()) + put(PROFILE_FAMILY_NAME, profileName.familyName.nullIfBlank()) + put(PROFILE_JOINED_NAME, profileName.toString().nullIfBlank()) } if (update(id, contentValues)) { rotateStorageId(id) @@ -3177,7 +3177,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da .where("$ID LIKE ? OR $ACI_COLUMN LIKE ? OR $PNI_COLUMN LIKE ?", "%$query%", "%$query%", "%$query%") .run() .readToList { cursor -> - getRecord(context, cursor) + RecipientTableCursorUtil.getRecord(context, cursor) } } @@ -3650,7 +3650,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da .run() .use { cursor -> return if (cursor.moveToFirst()) { - readCapabilities(cursor) + RecipientTableCursorUtil.readCapabilities(cursor) } else { null } @@ -4110,196 +4110,6 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da RecipientId.clearCache() } - fun getRecord(context: Context, cursor: Cursor): RecipientRecord { - return getRecord(context, cursor, ID) - } - - fun getRecord(context: Context, cursor: Cursor, idColumnName: String): RecipientRecord { - val profileKeyString = cursor.requireString(PROFILE_KEY) - val expiringProfileKeyCredentialString = cursor.requireString(EXPIRING_PROFILE_KEY_CREDENTIAL) - var profileKey: ByteArray? = null - var expiringProfileKeyCredential: ExpiringProfileKeyCredential? = null - - if (profileKeyString != null) { - try { - profileKey = Base64.decode(profileKeyString) - } catch (e: IOException) { - Log.w(TAG, e) - } - - if (expiringProfileKeyCredentialString != null) { - try { - val columnDataBytes = Base64.decode(expiringProfileKeyCredentialString) - val columnData = ExpiringProfileKeyCredentialColumnData.ADAPTER.decode(columnDataBytes) - if (Arrays.equals(columnData.profileKey.toByteArray(), profileKey)) { - expiringProfileKeyCredential = ExpiringProfileKeyCredential(columnData.expiringProfileKeyCredential.toByteArray()) - } else { - Log.i(TAG, "Out of date profile key credential data ignored on read") - } - } catch (e: InvalidInputException) { - Log.w(TAG, "Profile key credential column data could not be read", e) - } catch (e: IOException) { - Log.w(TAG, "Profile key credential column data could not be read", e) - } - } - } - - val serializedWallpaper = cursor.requireBlob(WALLPAPER) - val chatWallpaper: ChatWallpaper? = if (serializedWallpaper != null) { - try { - ChatWallpaperFactory.create(Wallpaper.ADAPTER.decode(serializedWallpaper)) - } catch (e: IOException) { - Log.w(TAG, "Failed to parse wallpaper.", e) - null - } - } else { - null - } - - val customChatColorsId = cursor.requireLong(CUSTOM_CHAT_COLORS_ID) - val serializedChatColors = cursor.requireBlob(CHAT_COLORS) - val chatColors: ChatColors? = if (serializedChatColors != null) { - try { - forChatColor(forLongValue(customChatColorsId), ChatColor.ADAPTER.decode(serializedChatColors)) - } catch (e: IOException) { - Log.w(TAG, "Failed to parse chat colors.", e) - null - } - } else { - null - } - - val recipientId = RecipientId.from(cursor.requireLong(idColumnName)) - val distributionListId: DistributionListId? = DistributionListId.fromNullable(cursor.requireLong(DISTRIBUTION_LIST_ID)) - val avatarColor: AvatarColor = if (distributionListId != null) AvatarColor.UNKNOWN else AvatarColor.deserialize(cursor.requireString(AVATAR_COLOR)) - - return RecipientRecord( - id = recipientId, - aci = ACI.parseOrNull(cursor.requireString(ACI_COLUMN)), - pni = PNI.parsePrefixedOrNull(cursor.requireString(PNI_COLUMN)), - username = cursor.requireString(USERNAME), - e164 = cursor.requireString(E164), - email = cursor.requireString(EMAIL), - groupId = GroupId.parseNullableOrThrow(cursor.requireString(GROUP_ID)), - distributionListId = distributionListId, - recipientType = RecipientType.fromId(cursor.requireInt(TYPE)), - isBlocked = cursor.requireBoolean(BLOCKED), - muteUntil = cursor.requireLong(MUTE_UNTIL), - messageVibrateState = VibrateState.fromId(cursor.requireInt(MESSAGE_VIBRATE)), - callVibrateState = VibrateState.fromId(cursor.requireInt(CALL_VIBRATE)), - messageRingtone = Util.uri(cursor.requireString(MESSAGE_RINGTONE)), - callRingtone = Util.uri(cursor.requireString(CALL_RINGTONE)), - expireMessages = cursor.requireInt(MESSAGE_EXPIRATION_TIME), - registered = RegisteredState.fromId(cursor.requireInt(REGISTERED)), - profileKey = profileKey, - expiringProfileKeyCredential = expiringProfileKeyCredential, - systemProfileName = ProfileName.fromParts(cursor.requireString(SYSTEM_GIVEN_NAME), cursor.requireString(SYSTEM_FAMILY_NAME)), - systemDisplayName = cursor.requireString(SYSTEM_JOINED_NAME), - systemContactPhotoUri = cursor.requireString(SYSTEM_PHOTO_URI), - systemPhoneLabel = cursor.requireString(SYSTEM_PHONE_LABEL), - systemContactUri = cursor.requireString(SYSTEM_CONTACT_URI), - signalProfileName = ProfileName.fromParts(cursor.requireString(PROFILE_GIVEN_NAME), cursor.requireString(PROFILE_FAMILY_NAME)), - signalProfileAvatar = cursor.requireString(PROFILE_AVATAR), - profileAvatarFileDetails = AvatarHelper.getAvatarFileDetails(context, recipientId), - profileSharing = cursor.requireBoolean(PROFILE_SHARING), - lastProfileFetch = cursor.requireLong(LAST_PROFILE_FETCH), - notificationChannel = cursor.requireString(NOTIFICATION_CHANNEL), - unidentifiedAccessMode = UnidentifiedAccessMode.fromMode(cursor.requireInt(SEALED_SENDER_MODE)), - capabilities = readCapabilities(cursor), - storageId = Base64.decodeNullableOrThrow(cursor.requireString(STORAGE_SERVICE_ID)), - mentionSetting = MentionSetting.fromId(cursor.requireInt(MENTION_SETTING)), - wallpaper = chatWallpaper, - chatColors = chatColors, - avatarColor = avatarColor, - about = cursor.requireString(ABOUT), - aboutEmoji = cursor.requireString(ABOUT_EMOJI), - syncExtras = getSyncExtras(cursor), - extras = getExtras(cursor), - hasGroupsInCommon = cursor.requireBoolean(GROUPS_IN_COMMON), - badges = parseBadgeList(cursor.requireBlob(BADGES)), - needsPniSignature = cursor.requireBoolean(NEEDS_PNI_SIGNATURE), - hiddenState = Recipient.HiddenState.deserialize(cursor.requireInt(HIDDEN)), - callLinkRoomId = cursor.requireString(CALL_LINK_ROOM_ID)?.let { CallLinkRoomId.DatabaseSerializer.deserialize(it) } - ) - } - - private fun readCapabilities(cursor: Cursor): RecipientRecord.Capabilities { - val capabilities = cursor.requireLong(CAPABILITIES) - return RecipientRecord.Capabilities( - rawBits = capabilities, - groupsV1MigrationCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.GROUPS_V1_MIGRATION, Capabilities.BIT_LENGTH).toInt()), - senderKeyCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.SENDER_KEY, Capabilities.BIT_LENGTH).toInt()), - announcementGroupCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.ANNOUNCEMENT_GROUPS, Capabilities.BIT_LENGTH).toInt()), - changeNumberCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.CHANGE_NUMBER, Capabilities.BIT_LENGTH).toInt()), - storiesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.STORIES, Capabilities.BIT_LENGTH).toInt()), - giftBadgesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.GIFT_BADGES, Capabilities.BIT_LENGTH).toInt()), - pnpCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.PNP, Capabilities.BIT_LENGTH).toInt()), - paymentActivation = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.PAYMENT_ACTIVATION, Capabilities.BIT_LENGTH).toInt()) - ) - } - - private fun parseBadgeList(serializedBadgeList: ByteArray?): List { - var badgeList: BadgeList? = null - if (serializedBadgeList != null) { - try { - badgeList = BadgeList.ADAPTER.decode(serializedBadgeList) - } catch (e: IOException) { - Log.w(TAG, e) - } - } - - val badges: List - if (badgeList != null) { - val protoBadges = badgeList.badges - badges = ArrayList(protoBadges.size) - for (protoBadge in protoBadges) { - badges.add(Badges.fromDatabaseBadge(protoBadge)) - } - } else { - badges = emptyList() - } - - return badges - } - - private fun getSyncExtras(cursor: Cursor): RecipientRecord.SyncExtras { - val storageProtoRaw = cursor.optionalString(STORAGE_SERVICE_PROTO).orElse(null) - val storageProto = if (storageProtoRaw != null) Base64.decodeOrThrow(storageProtoRaw) else null - val archived = cursor.optionalBoolean(ThreadTable.ARCHIVED).orElse(false) - val forcedUnread = cursor.optionalInt(ThreadTable.READ).map { status: Int -> status == ThreadTable.ReadStatus.FORCED_UNREAD.serialize() }.orElse(false) - val groupMasterKey = cursor.optionalBlob(GroupTable.V2_MASTER_KEY).map { GroupUtil.requireMasterKey(it) }.orElse(null) - val identityKey = cursor.optionalString(IDENTITY_KEY).map { Base64.decodeOrThrow(it) }.orElse(null) - val identityStatus = cursor.optionalInt(IDENTITY_STATUS).map { VerifiedStatus.forState(it) }.orElse(VerifiedStatus.DEFAULT) - val unregisteredTimestamp = cursor.optionalLong(UNREGISTERED_TIMESTAMP).orElse(0) - val systemNickname = cursor.optionalString(SYSTEM_NICKNAME).orElse(null) - - return RecipientRecord.SyncExtras( - storageProto = storageProto, - groupMasterKey = groupMasterKey, - identityKey = identityKey, - identityStatus = identityStatus, - isArchived = archived, - isForcedUnread = forcedUnread, - unregisteredTimestamp = unregisteredTimestamp, - systemNickname = systemNickname - ) - } - - private fun getExtras(cursor: Cursor): Recipient.Extras? { - return Recipient.Extras.from(getRecipientExtras(cursor)) - } - - private fun getRecipientExtras(cursor: Cursor): RecipientExtras? { - return cursor.optionalBlob(EXTRAS).map { b: ByteArray -> - try { - RecipientExtras.ADAPTER.decode(b) - } catch (e: IOException) { - Log.w(TAG, e) - throw AssertionError(e) - } - }.orElse(null) - } - private fun updateProfileValuesForMerge(values: ContentValues, record: RecipientRecord) { values.apply { put(PROFILE_KEY, if (record.profileKey != null) Base64.encodeWithPadding(record.profileKey) else null) @@ -4430,6 +4240,28 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } } + class RecipientIterator( + private val context: Context, + private val cursor: Cursor + ) : Iterator, Closeable { + + override fun hasNext(): Boolean { + return cursor.count != 0 && !cursor.isLast + } + + override fun next(): RecipientRecord { + if (!cursor.moveToNext()) { + throw NoSuchElementException() + } + + return RecipientTableCursorUtil.getRecord(context, cursor) + } + + override fun close() { + cursor.close() + } + } + class MissingRecipientException(id: RecipientId?) : IllegalStateException("Failed to find recipient with ID: $id") private class GetOrInsertResult(val recipientId: RecipientId, val neededInsert: Boolean) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt new file mode 100644 index 0000000000..f68ffe81a2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTableCursorUtil.kt @@ -0,0 +1,247 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.database + +import android.content.Context +import android.database.Cursor +import com.google.protobuf.InvalidProtocolBufferException +import org.signal.core.util.Base64 +import org.signal.core.util.Bitmask +import org.signal.core.util.logging.Log +import org.signal.core.util.optionalBlob +import org.signal.core.util.optionalBoolean +import org.signal.core.util.optionalInt +import org.signal.core.util.optionalLong +import org.signal.core.util.optionalString +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.requireString +import org.signal.libsignal.zkgroup.InvalidInputException +import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential +import org.thoughtcrime.securesms.badges.Badges +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.conversation.colors.AvatarColor +import org.thoughtcrime.securesms.conversation.colors.ChatColors +import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus +import org.thoughtcrime.securesms.database.RecipientTable.Capabilities +import org.thoughtcrime.securesms.database.RecipientTable.RegisteredState +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.database.model.RecipientRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList +import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor +import org.thoughtcrime.securesms.database.model.databaseprotos.ExpiringProfileKeyCredentialColumnData +import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras +import org.thoughtcrime.securesms.database.model.databaseprotos.Wallpaper +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.profiles.AvatarHelper +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId +import org.thoughtcrime.securesms.util.GroupUtil +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperFactory +import org.whispersystems.signalservice.api.push.ServiceId +import java.io.IOException +import java.util.Arrays + +object RecipientTableCursorUtil { + + private val TAG = Log.tag(RecipientTableCursorUtil::class.java) + + fun getRecord(context: Context, cursor: Cursor): RecipientRecord { + return getRecord(context, cursor, RecipientTable.ID) + } + + fun getRecord(context: Context, cursor: Cursor, idColumnName: String): RecipientRecord { + val profileKeyString = cursor.requireString(RecipientTable.PROFILE_KEY) + val expiringProfileKeyCredentialString = cursor.requireString(RecipientTable.EXPIRING_PROFILE_KEY_CREDENTIAL) + var profileKey: ByteArray? = null + var expiringProfileKeyCredential: ExpiringProfileKeyCredential? = null + + if (profileKeyString != null) { + try { + profileKey = Base64.decode(profileKeyString) + } catch (e: IOException) { + Log.w(TAG, e) + } + + if (expiringProfileKeyCredentialString != null) { + try { + val columnDataBytes = Base64.decode(expiringProfileKeyCredentialString) + val columnData = ExpiringProfileKeyCredentialColumnData.ADAPTER.decode(columnDataBytes) + if (Arrays.equals(columnData.profileKey.toByteArray(), profileKey)) { + expiringProfileKeyCredential = ExpiringProfileKeyCredential(columnData.expiringProfileKeyCredential.toByteArray()) + } else { + Log.i(TAG, "Out of date profile key credential data ignored on read") + } + } catch (e: InvalidInputException) { + Log.w(TAG, "Profile key credential column data could not be read", e) + } catch (e: IOException) { + Log.w(TAG, "Profile key credential column data could not be read", e) + } + } + } + + val serializedWallpaper = cursor.requireBlob(RecipientTable.WALLPAPER) + val chatWallpaper: ChatWallpaper? = if (serializedWallpaper != null) { + try { + ChatWallpaperFactory.create(Wallpaper.ADAPTER.decode(serializedWallpaper)) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, "Failed to parse wallpaper.", e) + null + } + } else { + null + } + + val customChatColorsId = cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID) + val serializedChatColors = cursor.requireBlob(RecipientTable.CHAT_COLORS) + val chatColors: ChatColors? = if (serializedChatColors != null) { + try { + ChatColors.forChatColor(ChatColors.Id.forLongValue(customChatColorsId), ChatColor.ADAPTER.decode(serializedChatColors)) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, "Failed to parse chat colors.", e) + null + } + } else { + null + } + + val recipientId = RecipientId.from(cursor.requireLong(idColumnName)) + val distributionListId: DistributionListId? = DistributionListId.fromNullable(cursor.requireLong(RecipientTable.DISTRIBUTION_LIST_ID)) + val avatarColor: AvatarColor = if (distributionListId != null) AvatarColor.UNKNOWN else AvatarColor.deserialize(cursor.requireString(RecipientTable.AVATAR_COLOR)) + + return RecipientRecord( + id = recipientId, + aci = ServiceId.ACI.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN)), + pni = ServiceId.PNI.parsePrefixedOrNull(cursor.requireString(RecipientTable.PNI_COLUMN)), + username = cursor.requireString(RecipientTable.USERNAME), + e164 = cursor.requireString(RecipientTable.E164), + email = cursor.requireString(RecipientTable.EMAIL), + groupId = GroupId.parseNullableOrThrow(cursor.requireString(RecipientTable.GROUP_ID)), + distributionListId = distributionListId, + recipientType = RecipientTable.RecipientType.fromId(cursor.requireInt(RecipientTable.TYPE)), + isBlocked = cursor.requireBoolean(RecipientTable.BLOCKED), + muteUntil = cursor.requireLong(RecipientTable.MUTE_UNTIL), + messageVibrateState = RecipientTable.VibrateState.fromId(cursor.requireInt(RecipientTable.MESSAGE_VIBRATE)), + callVibrateState = RecipientTable.VibrateState.fromId(cursor.requireInt(RecipientTable.CALL_VIBRATE)), + messageRingtone = Util.uri(cursor.requireString(RecipientTable.MESSAGE_RINGTONE)), + callRingtone = Util.uri(cursor.requireString(RecipientTable.CALL_RINGTONE)), + expireMessages = cursor.requireInt(RecipientTable.MESSAGE_EXPIRATION_TIME), + registered = RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED)), + profileKey = profileKey, + expiringProfileKeyCredential = expiringProfileKeyCredential, + systemProfileName = ProfileName.fromParts(cursor.requireString(RecipientTable.SYSTEM_GIVEN_NAME), cursor.requireString(RecipientTable.SYSTEM_FAMILY_NAME)), + systemDisplayName = cursor.requireString(RecipientTable.SYSTEM_JOINED_NAME), + systemContactPhotoUri = cursor.requireString(RecipientTable.SYSTEM_PHOTO_URI), + systemPhoneLabel = cursor.requireString(RecipientTable.SYSTEM_PHONE_LABEL), + systemContactUri = cursor.requireString(RecipientTable.SYSTEM_CONTACT_URI), + signalProfileName = ProfileName.fromParts(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME), cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME)), + signalProfileAvatar = cursor.requireString(RecipientTable.PROFILE_AVATAR), + profileAvatarFileDetails = AvatarHelper.getAvatarFileDetails(context, recipientId), + profileSharing = cursor.requireBoolean(RecipientTable.PROFILE_SHARING), + lastProfileFetch = cursor.requireLong(RecipientTable.LAST_PROFILE_FETCH), + notificationChannel = cursor.requireString(RecipientTable.NOTIFICATION_CHANNEL), + unidentifiedAccessMode = RecipientTable.UnidentifiedAccessMode.fromMode(cursor.requireInt(RecipientTable.SEALED_SENDER_MODE)), + capabilities = readCapabilities(cursor), + storageId = Base64.decodeNullableOrThrow(cursor.requireString(RecipientTable.STORAGE_SERVICE_ID)), + mentionSetting = RecipientTable.MentionSetting.fromId(cursor.requireInt(RecipientTable.MENTION_SETTING)), + wallpaper = chatWallpaper, + chatColors = chatColors, + avatarColor = avatarColor, + about = cursor.requireString(RecipientTable.ABOUT), + aboutEmoji = cursor.requireString(RecipientTable.ABOUT_EMOJI), + syncExtras = getSyncExtras(cursor), + extras = getExtras(cursor), + hasGroupsInCommon = cursor.requireBoolean(RecipientTable.GROUPS_IN_COMMON), + badges = parseBadgeList(cursor.requireBlob(RecipientTable.BADGES)), + needsPniSignature = cursor.requireBoolean(RecipientTable.NEEDS_PNI_SIGNATURE), + hiddenState = Recipient.HiddenState.deserialize(cursor.requireInt(RecipientTable.HIDDEN)), + callLinkRoomId = cursor.requireString(RecipientTable.CALL_LINK_ROOM_ID)?.let { CallLinkRoomId.DatabaseSerializer.deserialize(it) } + ) + } + + fun readCapabilities(cursor: Cursor): RecipientRecord.Capabilities { + val capabilities = cursor.requireLong(RecipientTable.CAPABILITIES) + return RecipientRecord.Capabilities( + rawBits = capabilities, + groupsV1MigrationCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.GROUPS_V1_MIGRATION, Capabilities.BIT_LENGTH).toInt()), + senderKeyCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.SENDER_KEY, Capabilities.BIT_LENGTH).toInt()), + announcementGroupCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.ANNOUNCEMENT_GROUPS, Capabilities.BIT_LENGTH).toInt()), + changeNumberCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.CHANGE_NUMBER, Capabilities.BIT_LENGTH).toInt()), + storiesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.STORIES, Capabilities.BIT_LENGTH).toInt()), + giftBadgesCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.GIFT_BADGES, Capabilities.BIT_LENGTH).toInt()), + pnpCapability = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.PNP, Capabilities.BIT_LENGTH).toInt()), + paymentActivation = Recipient.Capability.deserialize(Bitmask.read(capabilities, Capabilities.PAYMENT_ACTIVATION, Capabilities.BIT_LENGTH).toInt()) + ) + } + + fun parseBadgeList(serializedBadgeList: ByteArray?): List { + var badgeList: BadgeList? = null + if (serializedBadgeList != null) { + try { + badgeList = BadgeList.ADAPTER.decode(serializedBadgeList) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, e) + } + } + + val badges: List + if (badgeList != null) { + val protoBadges = badgeList.badges + badges = ArrayList(protoBadges.size) + for (protoBadge in protoBadges) { + badges.add(Badges.fromDatabaseBadge(protoBadge)) + } + } else { + badges = emptyList() + } + + return badges + } + + fun getSyncExtras(cursor: Cursor): RecipientRecord.SyncExtras { + val storageProtoRaw = cursor.optionalString(RecipientTable.STORAGE_SERVICE_PROTO).orElse(null) + val storageProto = if (storageProtoRaw != null) Base64.decodeOrThrow(storageProtoRaw) else null + val archived = cursor.optionalBoolean(ThreadTable.ARCHIVED).orElse(false) + val forcedUnread = cursor.optionalInt(ThreadTable.READ).map { status: Int -> status == ThreadTable.ReadStatus.FORCED_UNREAD.serialize() }.orElse(false) + val groupMasterKey = cursor.optionalBlob(GroupTable.V2_MASTER_KEY).map { GroupUtil.requireMasterKey(it) }.orElse(null) + val identityKey = cursor.optionalString(RecipientTable.IDENTITY_KEY).map { Base64.decodeOrThrow(it) }.orElse(null) + val identityStatus = cursor.optionalInt(RecipientTable.IDENTITY_STATUS).map { VerifiedStatus.forState(it) }.orElse(VerifiedStatus.DEFAULT) + val unregisteredTimestamp = cursor.optionalLong(RecipientTable.UNREGISTERED_TIMESTAMP).orElse(0) + val systemNickname = cursor.optionalString(RecipientTable.SYSTEM_NICKNAME).orElse(null) + + return RecipientRecord.SyncExtras( + storageProto = storageProto, + groupMasterKey = groupMasterKey, + identityKey = identityKey, + identityStatus = identityStatus, + isArchived = archived, + isForcedUnread = forcedUnread, + unregisteredTimestamp = unregisteredTimestamp, + systemNickname = systemNickname + ) + } + + fun getExtras(cursor: Cursor): Recipient.Extras? { + return Recipient.Extras.from(getRecipientExtras(cursor)) + } + + fun getRecipientExtras(cursor: Cursor): RecipientExtras? { + return cursor.optionalBlob(RecipientTable.EXTRAS).map { b: ByteArray -> + try { + RecipientExtras.ADAPTER.decode(b) + } catch (e: InvalidProtocolBufferException) { + Log.w(TAG, e) + throw AssertionError(e) + } + }.orElse(null) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index a04d0208fd..0a0dfb89e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -1807,6 +1807,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa ) } + fun clearCache() { + threadIdCache.clear() + } + private fun createQuery(where: String, orderBy: String, offset: Long, limit: Long): String { val projection = COMBINED_THREAD_RECIPIENT_GROUP_PROJECTION.joinToString(separator = ",") @@ -1879,7 +1883,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa open fun getCurrent(): ThreadRecord? { val recipientId = RecipientId.from(cursor.requireLong(RECIPIENT_ID)) - val recipientSettings = recipients.getRecord(context, cursor, RECIPIENT_ID) + val recipientSettings = RecipientTableCursorUtil.getRecord(context, cursor, RECIPIENT_ID) val recipient: Recipient = if (recipientSettings.groupId != null) { GroupTable.Reader(cursor).getCurrent()?.let { group -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatch.java b/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatch.java index e9b1157787..c573939380 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatch.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/documents/IdentityKeyMismatch.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.InvalidKeyException; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.signal.core.util.Base64; @@ -51,11 +52,11 @@ public class IdentityKeyMismatch { } @JsonIgnore - public RecipientId getRecipientId(@NonNull Context context) { + public RecipientId getRecipientId() { if (!TextUtils.isEmpty(recipientId)) { return RecipientId.from(recipientId); } else { - return Recipient.external(context, address).getId(); + return Recipient.external(ApplicationDependencies.getApplication(), address).getId(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailure.java b/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailure.java index 119c6c148d..b83ab2a7ac 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailure.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/documents/NetworkFailure.java @@ -8,6 +8,8 @@ import androidx.annotation.NonNull; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -30,11 +32,11 @@ public class NetworkFailure { public NetworkFailure() {} @JsonIgnore - public RecipientId getRecipientId(@NonNull Context context) { + public RecipientId getRecipientId() { if (!TextUtils.isEmpty(recipientId)) { return RecipientId.from(recipientId); } else { - return Recipient.external(context, address).getId(); + return Recipient.external(ApplicationDependencies.getApplication(), address).getId(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java index b698aef5aa..23eae8b92e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushDistributionListSendJob.java @@ -169,9 +169,9 @@ public final class PushDistributionListSendJob extends PushSendJob { if (Util.hasItems(filterRecipientIds)) { targets = new ArrayList<>(filterRecipientIds.size() + existingNetworkFailures.size()); targets.addAll(filterRecipientIds.stream().map(Recipient::resolved).collect(Collectors.toList())); - targets.addAll(existingNetworkFailures.stream().map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).collect(Collectors.toList())); + targets.addAll(existingNetworkFailures.stream().map(NetworkFailure::getRecipientId).distinct().map(Recipient::resolved).collect(Collectors.toList())); } else if (!existingNetworkFailures.isEmpty()) { - targets = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).toList(); + targets = Stream.of(existingNetworkFailures).map(NetworkFailure::getRecipientId).distinct().map(Recipient::resolved).toList(); } else { Stories.SendData data = Stories.getRecipientsToSendTo(messageId, message.getSentTimeMillis(), message.getStoryType().isStoryWithReplies()); targets = data.getTargets(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index 688920f09f..9d174a1ac8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -221,9 +221,9 @@ public final class PushGroupSendJob extends PushSendJob { if (Util.hasItems(filterRecipients)) { target = new ArrayList<>(filterRecipients.size() + existingNetworkFailures.size()); target.addAll(Stream.of(filterRecipients).map(Recipient::resolved).toList()); - target.addAll(Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).toList()); + target.addAll(Stream.of(existingNetworkFailures).map(NetworkFailure::getRecipientId).distinct().map(Recipient::resolved).toList()); } else if (!existingNetworkFailures.isEmpty()) { - target = Stream.of(existingNetworkFailures).map(nf -> nf.getRecipientId(context)).distinct().map(Recipient::resolved).toList(); + target = Stream.of(existingNetworkFailures).map(NetworkFailure::getRecipientId).distinct().map(Recipient::resolved).toList(); } else { GroupRecipientResult result = getGroupMessageRecipients(groupRecipient.requireGroupId(), messageId); @@ -421,8 +421,8 @@ public final class PushGroupSendJob extends PushSendJob { List successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList(); List> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(accessList.requireIdByAddress(result.getAddress()), result.getSuccess().isUnidentified())).toList(); Set successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet()); - Set resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId(context))).collect(Collectors.toSet()); - Set resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId(context))).collect(Collectors.toSet()); + Set resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId())).collect(Collectors.toSet()); + Set resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId())).collect(Collectors.toSet()); List unregisteredRecipients = Stream.of(results).filter(SendMessageResult::isUnregisteredFailure).map(result -> RecipientId.from(result.getAddress())).toList(); List invalidPreKeyRecipients = Stream.of(results).filter(SendMessageResult::isInvalidPreKeyFailure).map(result -> RecipientId.from(result.getAddress())).toList(); Set skippedRecipients = new HashSet<>(); @@ -442,12 +442,12 @@ public final class PushGroupSendJob extends PushSendJob { } existingNetworkFailures.removeAll(resolvedNetworkFailures); - existingNetworkFailures.removeIf(it -> skippedRecipients.contains(it.getRecipientId(context))); + existingNetworkFailures.removeIf(it -> skippedRecipients.contains(it.getRecipientId())); existingNetworkFailures.addAll(networkFailures); database.setNetworkFailures(messageId, existingNetworkFailures); existingIdentityMismatches.removeAll(resolvedIdentityFailures); - existingIdentityMismatches.removeIf(it -> skippedRecipients.contains(it.getRecipientId(context))); + existingIdentityMismatches.removeIf(it -> skippedRecipients.contains(it.getRecipientId())); existingIdentityMismatches.addAll(identityMismatches); database.setMismatchedIdentities(messageId, existingIdentityMismatches); @@ -485,7 +485,7 @@ public final class PushGroupSendJob extends PushSendJob { notifyMediaMessageDeliveryFailed(context, messageId); Set mismatchRecipientIds = Stream.of(existingIdentityMismatches) - .map(mismatch -> mismatch.getRecipientId(context)) + .map(mismatch -> mismatch.getRecipientId()) .collect(Collectors.toSet()); RetrieveProfileJob.enqueue(mismatchRecipientIds); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index 2356c7fc48..c4af2cca45 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -263,6 +263,30 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal } } + fun restorePniIdentityKeyFromBackup(publicKey: ByteArray, privateKey: ByteArray) { + synchronized(this) { + Log.i(TAG, "Setting a new PNI identity key pair.") + + store + .beginWrite() + .putBlob(KEY_PNI_IDENTITY_PUBLIC_KEY, publicKey) + .putBlob(KEY_PNI_IDENTITY_PRIVATE_KEY, privateKey) + .commit() + } + } + + fun restoreAciIdentityKeyFromBackup(publicKey: ByteArray, privateKey: ByteArray) { + synchronized(this) { + Log.i(TAG, "Setting a new ACI identity key pair.") + + store + .beginWrite() + .putBlob(KEY_ACI_IDENTITY_PUBLIC_KEY, publicKey) + .putBlob(KEY_ACI_IDENTITY_PRIVATE_KEY, privateKey) + .commit() + } + } + /** Only to be used when restoring an identity public key from an old backup */ fun restoreLegacyIdentityPublicKeyFromBackup(base64: String) { Log.w(TAG, "Restoring legacy identity public key from backup.") @@ -347,6 +371,18 @@ internal class AccountValues internal constructor(store: KeyValueStore) : Signal } } + /** + * Function for testing backup/restore + */ + @Deprecated("debug only") + fun clearRegistrationButKeepCredentials() { + putBoolean(KEY_IS_REGISTERED, false) + + ApplicationDependencies.getIncomingMessageObserver().notifyRegistrationChanged() + + Recipient.self().live().refresh() + } + val deviceName: String? get() = getString(KEY_DEVICE_NAME, null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt index ce86d2c621..f9a500b3ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/ReleaseChannelValues.kt @@ -32,6 +32,10 @@ internal class ReleaseChannelValues(store: KeyValueStore) : SignalStoreValues(st putString(KEY_RELEASE_CHANNEL_RECIPIENT_ID, id.serialize()) } + fun clearReleaseChannelRecipientId() { + putString(KEY_RELEASE_CHANNEL_RECIPIENT_ID, "") + } + var nextScheduledCheck by longValue(KEY_NEXT_SCHEDULED_CHECK, 0) var previousManifestMd5 by blobValue(KEY_PREVIOUS_MANIFEST_MD5, ByteArray(0)) var highestVersionNoteReceived by integerValue(KEY_HIGHEST_VERSION_NOTE_RECEIVED, 0) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java index adc7f6ca73..bbb0e8ee04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SignalStore.java @@ -301,4 +301,9 @@ public final class SignalStore { public static void inject(@NonNull KeyValueStore store) { instance = new SignalStore(store); } + + public static void clearAllDataForBackupRestore() { + releaseChannelValues().clearReleaseChannelRecipientId(); + account().clearRegistrationButKeepCredentials(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java index f66e3a7b9f..076a431494 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsRepository.java @@ -138,7 +138,7 @@ public final class MessageDetailsRepository { private @Nullable NetworkFailure getNetworkFailure(MessageRecord messageRecord, Recipient recipient) { if (messageRecord.hasNetworkFailures()) { for (final NetworkFailure failure : messageRecord.getNetworkFailures()) { - if (failure.getRecipientId(context).equals(recipient.getId())) { + if (failure.getRecipientId().equals(recipient.getId())) { return failure; } } @@ -149,7 +149,7 @@ public final class MessageDetailsRepository { private @Nullable IdentityKeyMismatch getKeyMismatchFailure(MessageRecord messageRecord, Recipient recipient) { if (messageRecord.isIdentityMismatchFailure()) { for (final IdentityKeyMismatch mismatch : messageRecord.getIdentityKeyMismatches()) { - if (mismatch.getRecipientId(context).equals(recipient.getId())) { + if (mismatch.getRecipientId().equals(recipient.getId())) { return mismatch; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt index ff388953cf..1ce41bc749 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt @@ -82,7 +82,7 @@ object SafetyNumberBottomSheet { @JvmStatic fun forMessageRecord(context: Context, messageRecord: MessageRecord): Factory { val args = SafetyNumberBottomSheetArgs( - untrustedRecipients = messageRecord.identityKeyMismatches.map { it.getRecipientId(context) }, + untrustedRecipients = messageRecord.identityKeyMismatches.map { it.recipientId }, destinations = getDestinationFromRecord(messageRecord), messageId = MessageId(messageRecord.id) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 13129279a5..3be6fc371f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -200,6 +200,7 @@ public final class FeatureFlags { @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") @VisibleForTesting static final Map FORCED_VALUES = new HashMap() {{ + put(INTERNAL_USER, true); }}; /** diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto new file mode 100644 index 0000000000..b15514dcf1 --- /dev/null +++ b/app/src/main/protowire/Backup.proto @@ -0,0 +1,541 @@ +syntax = "proto3"; + +package signal.backup; + +option java_package = "org.thoughtcrime.securesms.backup.v2.proto"; + +message BackupInfo { + uint64 version = 1; + uint64 backupTime = 2; +} + +message Frame { + oneof item { + AccountData account = 1; + Recipient recipient = 2; + Chat chat = 3; + ChatItem chatItem = 4; + Call call = 5; + StickerPack stickerPack = 6; + } +} + +message AccountData { + enum PhoneNumberSharingMode { + UNKNOWN = 0; + EVERYBODY = 1; + NOBODY = 2; + } + message UsernameLink { + enum Color { + UNKNOWN = 0; + BLUE = 1; + WHITE = 2; + GREY = 3; + OLIVE = 4; + GREEN = 5; + ORANGE = 6; + PINK = 7; + PURPLE = 8; + } + + bytes entropy = 1; // 32 bytes of entropy used for encryption + bytes serverId = 2; // 16 bytes of encoded UUID provided by the server + Color color = 3; + } + + message AccountSettings { + bool noteToSelfArchived = 1; + bool readReceipts = 2; + bool sealedSenderIndicators = 3; + bool typingIndicators = 4; + bool proxiedLinkPreviews = 5; + bool noteToSelfMarkedUnread = 6; + bool linkPreviews = 7; + bool unlistedPhoneNumber = 8; + bool preferContactAvatars = 9; + uint32 universalExpireTimer = 10; + repeated string preferredReactionEmoji = 11; + bool displayBadgesOnProfile = 12; + bool keepMutedChatsArchived = 13; + bool hasSetMyStoriesPrivacy = 14; + bool hasViewedOnboardingStory = 15; + bool storiesDisabled = 16; + optional bool storyViewReceiptsEnabled = 17; + bool hasReadOnboardingStory = 18; + bool hasSeenGroupStoryEducationSheet = 19; + bool hasCompletedUsernameOnboarding = 20; + PhoneNumberSharingMode phoneNumberSharingMode = 21; + } + + bytes aciIdentityPublicKey = 1; + bytes aciIdentityPrivateKey = 2; + bytes pniIdentityPublicKey = 3; + bytes pniIdentityPrivateKey = 4; + bytes profileKey = 5; + optional string username = 6; + UsernameLink usernameLink = 7; + string givenName = 8; + string familyName = 9; + string avatarUrlPath = 10; + bytes subscriberId = 11; + string subscriberCurrencyCode = 12; + bool subscriptionManuallyCancelled = 13; + AccountSettings accountSettings = 14; + bytes aci = 15; + bytes pni = 16; + uint64 e164 = 17; +} + +message Recipient { + uint64 id = 1; // generated id for reference only within this file + oneof destination { + Contact contact = 2; + Group group = 3; + DistributionList distributionList = 4; + Self self = 5; + } +} + +message Contact { + optional bytes aci = 1; // should be 16 bytes + optional bytes pni = 2; // should be 16 bytes + optional string username = 3; + optional uint64 e164 = 4; + bool blocked = 5; + bool hidden = 6; + enum Registered { + UNKNOWN = 0; + REGISTERED = 1; + NOT_REGISTERED = 2; + } + Registered registered = 7; + uint64 unregisteredTimestamp = 8; + optional bytes profileKey = 9; + bool profileSharing = 10; + optional string profileGivenName = 11; + optional string profileFamilyName = 12; + optional string profileJoinedName = 13; + bool hideStory = 14; +} + +message Group { + enum StorySendMode { + DEFAULT = 0; + DISABLED = 1; + ENABLED = 2; + } + + bytes masterKey = 1; + bool whitelisted = 2; + bool hideStory = 3; + StorySendMode storySendMode = 4; +} + +message Self {} + +message Chat { + uint64 id = 1; // generated id for reference only within this file + uint64 recipientId = 2; + bool archived = 3; + bool pinned = 4; + uint64 expirationTimer = 5; + uint64 muteUntil = 6; + bool markedUnread = 7; + bool dontNotifyForMentionsIfMuted = 8; +} + +message DistributionList { + string name = 1; + bytes distributionId = 2; // distribution list ids are uuids + bool allowReplies = 3; + uint64 deletionTimestamp = 4; + bool isUnknown = 5; + enum PrivacyMode { + ONLY_WITH = 0; + ALL_EXCEPT = 1; + ALL = 2; + } + PrivacyMode privacyMode = 6; + repeated uint64 memberRecipientIds = 7; // generated recipient id +} + +message Identity { + bytes serviceId = 1; + bytes identityKey = 2; + uint64 timestamp = 3; + bool firstUse = 4; + bool verified = 5; + bool nonblockingApproval = 6; +} + +message Call { + uint64 callId = 1; + uint64 peerRecipientId = 2; + enum Type { + AUDIO_CALL = 0; + VIDEO_CALL = 1; + GROUP_CALL = 2; + AD_HOC_CALL = 3; + } + Type type = 3; + bool outgoing = 4; + uint64 timestamp = 5; + uint64 ringerRecipientId = 6; + enum Event { + OUTGOING = 0; // 1:1 calls only + ACCEPTED = 1; // 1:1 and group calls. Group calls: You accepted a ring. + NOT_ACCEPTED = 2; // 1:1 calls only, + MISSED = 3; // 1:1 and group/ad-hoc calls. Group calls: The remote ring has expired or was cancelled by the ringer. + DELETE = 4; // 1:1 and Group/Ad-Hoc Calls. + GENERIC_GROUP_CALL = 5; // Group/Ad-Hoc Calls only. Initial state + JOINED = 6; // Group Calls: User has joined the group call. + RINGING = 7; // Group Calls: If a ring was requested by another user. + DECLINED = 8; // Group Calls: If you declined a ring. + OUTGOING_RING = 9; // Group Calls: If you are ringing a group. + } + Event event = 7; +} + +message ChatItem { + message IncomingMessageDetails { + uint64 dateServerSent = 1; + bool read = 2; + bool sealedSender = 3; + } + + message OutgoingMessageDetails { + repeated SendStatus sendStatus = 1; + } + + uint64 chatId = 1; // conversation id + uint64 authorId = 2; // recipient id + uint64 dateSent = 3; + uint64 dateReceived = 4; + optional uint64 expireStart = 5; // timestamp of when expiration timer started ticking down + optional uint64 expiresIn = 6; // how long timer of message is (ms) + repeated ChatItem revisions = 7; + bool sms = 8; + + oneof directionalDetails { + IncomingMessageDetails incoming = 9; + OutgoingMessageDetails outgoing = 10; + } + + oneof item { + StandardMessage standardMessage = 11; + ContactMessage contactMessage = 12; + VoiceMessage voiceMessage = 13; + StickerMessage stickerMessage = 14; + RemoteDeletedMessage remoteDeletedMessage = 15; + UpdateMessage updateMessage = 16; + } +} + +message SendStatus { + enum Status { + FAILED = 0; + PENDING = 1; + SENT = 2; + DELIVERED = 3; + READ = 4; + VIEWED = 5; + SKIPPED = 6; // e.g. user in group was blocked, so we skipped sending to them + } + uint64 recipientId = 1; + Status deliveryStatus = 2; + bool networkFailure = 3; + bool identityKeyMismatch = 4; + bool sealedSender = 5; + uint64 timestamp = 6; +} + +message Text { + string body = 1; + repeated BodyRange bodyRanges = 2; +} + +message StandardMessage { + optional Quote quote = 1; + optional Text text = 2; + repeated AttachmentPointer attachments = 3; + optional LinkPreview linkPreview = 4; + optional AttachmentPointer longText = 5; + repeated Reaction reactions = 6; +} + +message ContactMessage { + repeated ContactAttachment contact = 1; + repeated Reaction reactions = 2; +} + +message ContactAttachment { + message Name { + optional string givenName = 1; + optional string familyName = 2; + optional string prefix = 3; + optional string suffix = 4; + optional string middleName = 5; + optional string displayName = 6; + } + + message Phone { + enum Type { + HOME = 0; + MOBILE = 1; + WORK = 2; + CUSTOM = 3; + } + + optional string value = 1; + optional Type type = 2; + optional string label = 3; + } + + message Email { + enum Type { + HOME = 0; + MOBILE = 1; + WORK = 2; + CUSTOM = 3; + } + + optional string value = 1; + optional Type type = 2; + optional string label = 3; + } + + message PostalAddress { + enum Type { + HOME = 0; + WORK = 1; + CUSTOM = 2; + } + + optional Type type = 1; + optional string label = 2; + optional string street = 3; + optional string pobox = 4; + optional string neighborhood = 5; + optional string city = 6; + optional string region = 7; + optional string postcode = 8; + optional string country = 9; + } + + message Avatar { + optional AttachmentPointer avatar = 1; + optional bool isProfile = 2; + } + + optional Name name = 1; + repeated Phone number = 3; + repeated Email email = 4; + repeated PostalAddress address = 5; + optional Avatar avatar = 6; + optional string organization = 7; +} + +message DocumentMessage { + Text text = 1; + AttachmentPointer document = 2; + repeated Reaction reactions = 3; +} + +message VoiceMessage { + optional Quote quote = 1; + AttachmentPointer audio = 2; + repeated Reaction reactions = 3; +} + +message StickerMessage { + Sticker sticker = 1; + repeated Reaction reactions = 2; +} + +// Tombstone for remote delete +message RemoteDeletedMessage {} + +message ScheduledMessage { + ChatItem message = 1; + uint64 scheduledTime = 2; +} + +message Sticker { + bytes packId = 1; + bytes packKey = 2; + uint32 stickerId = 3; + optional string emoji = 4; +} + +message LinkPreview { + string url = 1; + optional string title = 2; + optional AttachmentPointer image = 3; + optional string description = 4; + optional uint64 date = 5; +} + +message AttachmentPointer { + enum Flags { + VOICE_MESSAGE = 0; + BORDERLESS = 1; + GIF = 2; + } + + oneof attachmentIdentifier { + fixed64 cdnId = 1; + string cdnKey = 2; + } + + optional string contentType = 3; + optional bytes key = 4; + optional uint32 size = 5; + optional bytes digest = 6; + optional bytes incrementalMac = 7; + optional bytes incrementalMacChunkSize = 8; + optional string fileName = 9; + optional uint32 flags = 10; + optional uint32 width = 11; + optional uint32 height = 12; + optional string caption = 13; + optional string blurHash = 14; + optional uint64 uploadTimestamp = 15; + optional uint32 cdnNumber = 16; +} + +message Quote { + enum Type { + UNKNOWN = 0; + NORMAL = 1; + GIFTBADGE = 2; + } + + message QuotedAttachment { + optional string contentType = 1; + optional string fileName = 2; + optional AttachmentPointer thumbnail = 3; + } + + uint64 targetSentTimestamp = 1; + uint64 authorId = 2; + optional string text = 3; + repeated QuotedAttachment attachments = 4; + repeated BodyRange bodyRanges = 5; + Type type = 6; + bool originalMessageMissing = 7; +} + +message BodyRange { + enum Style { + NONE = 0; + BOLD = 1; + ITALIC = 2; + SPOILER = 3; + STRIKETHROUGH = 4; + MONOSPACE = 5; + } + + optional uint32 start = 1; + optional uint32 length = 2; + + oneof associatedValue { + string mentionAci = 3; + Style style = 4; + } +} + +message Reaction { + string emoji = 1; + uint64 authorId = 2; + uint64 sentTimestamp = 3; + uint64 receivedTimestamp = 4; +} + +message UpdateMessage { + oneof update { + SimpleUpdate simpleUpdate = 1; + GroupDescriptionUpdate groupDescription = 2; + ExpirationTimerChange expirationTimerChange = 3; + ProfileChange profileChange = 4; + ThreadMergeEvent threadMerge = 5; + SessionSwitchoverEvent sessionSwitchover = 6; + CallingMessage callingMessage = 7; + } +} + +message CallingMessage { + oneof call { + uint64 callId = 1; // maps to id of Call from call log + CallMessage callMessage = 2; + GroupCallMessage groupCall = 3; + } +} + +message CallMessage { + enum Type { + INCOMING_AUDIO_CALL = 0; + INCOMING_VIDEO_CALL = 1; + OUTGOING_AUDIO_CALL = 2; + OUTGOING_VIDEO_CALL = 3; + MISSED_AUDIO_CALL = 4; + MISSED_VIDEO_CALL = 5; + } +} + +message GroupCallMessage { + bytes startedCallUuid = 1; + uint64 startedCallTimestamp = 2; + repeated bytes inCallUuids = 3; + bool isCallFull = 4; +} + +message SimpleUpdate { + enum Type { + JOINED_SIGNAL = 0; + IDENTITY_UPDATE = 1; + IDENTITY_VERIFIED = 2; + IDENTITY_DEFAULT = 3; // marking as unverified + CHANGE_NUMBER = 4; + BOOST_REQUEST = 5; + END_SESSION = 6; + CHAT_SESSION_REFRESH = 7; + BAD_DECRYPT = 8; + PAYMENTS_ACTIVATED = 9; + PAYMENT_ACTIVATION_REQUEST = 10; + } +} + +message GroupDescriptionUpdate { + string body = 1; +} + +message ExpirationTimerChange { + uint32 expiresIn = 1; +} + +message ProfileChange { + string previousName = 1; + string newName = 2; +} + +message ThreadMergeEvent { + uint64 previousE164 = 1; +} + +message SessionSwitchoverEvent { + uint64 e164 = 1; +} + +message StickerPack { + bytes id = 1; + bytes key = 2; + string title = 3; + string author = 4; + repeated StickerPackSticker stickers = 5; // First one should be cover sticker. +} + +message StickerPackSticker { + AttachmentPointer data = 1; + string emoji = 2; +} diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index c6e0be6e4b..0af5687f87 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -612,6 +612,9 @@ + + + diff --git a/core-util/src/main/java/org/signal/core/util/EventTimer.kt b/core-util/src/main/java/org/signal/core/util/EventTimer.kt new file mode 100644 index 0000000000..47c90eecba --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/EventTimer.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util + +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.DurationUnit + +/** + * Used to track performance metrics for large clusters of similar events. + * For instance, if you were doing a backup restore and had to important many different kinds of data in an unknown order, you could + * use this to learn stats around how long each kind of data takes to import. + * + * It is assumed that all events are happening serially with no delays in between. + * + * The timer tracks things at nanosecond granularity, but presents data as fractional milliseconds for readability. + */ +class EventTimer { + + private val durationsByGroup: MutableMap> = mutableMapOf() + + private val startTime = System.nanoTime() + private var lastTimeNanos: Long = startTime + + /** + * Indicates an event in the specified group has finished. + */ + fun emit(group: String) { + val now = System.nanoTime() + val duration = now - lastTimeNanos + + durationsByGroup.getOrPut(group) { mutableListOf() } += duration + + lastTimeNanos = now + } + + /** + * Stops the timer and returns a mapping of group -> [EventMetrics], which will tell you various statistics around timings for that group. + */ + fun stop(): EventTimerResults { + val data: Map = durationsByGroup + .mapValues { entry -> + val sorted: List = entry.value.sorted() + + EventMetrics( + totalTime = sorted.sum().nanoseconds.toDouble(DurationUnit.MILLISECONDS), + eventCount = sorted.size, + sortedDurationNanos = sorted + ) + } + + return EventTimerResults(data) + } + + class EventTimerResults(data: Map) : Map by data { + val summary by lazy { + val builder = StringBuilder() + + builder.append("[overall] totalTime: ${data.values.map { it.totalTime }.sum().roundedString(2)} ") + + for (entry in data) { + builder.append("[${entry.key}] totalTime: ${entry.value.totalTime.roundedString(2)}, count: ${entry.value.eventCount}, p50: ${entry.value.p(50)}, p90: ${entry.value.p(90)}, p99: ${entry.value.p(99)} ") + } + + builder.toString() + } + } + + data class EventMetrics( + /** The sum of all event durations, in fractional milliseconds. */ + val totalTime: Double, + /** Total number of events observed. */ + val eventCount: Int, + private val sortedDurationNanos: List + ) { + + /** + * Returns the percentile of the duration data (e.g. p50, p90) as a formatted string containing fractional milliseconds rounded to the requested number of decimal places. + */ + fun p(percentile: Int, decimalPlaces: Int = 2): String { + return pNanos(percentile).nanoseconds.toDouble(DurationUnit.MILLISECONDS).roundedString(decimalPlaces) + } + + private fun pNanos(percentile: Int): Long { + if (sortedDurationNanos.isEmpty()) { + return 0L + } + + val index: Float = (percentile / 100f) * (sortedDurationNanos.size - 1) + val lowerIndex: Int = floor(index).toInt() + val upperIndex: Int = ceil(index).toInt() + + if (lowerIndex == upperIndex) { + return sortedDurationNanos[lowerIndex] + } + + val interpolationFactor: Float = index - lowerIndex + val lowerValue: Long = sortedDurationNanos[lowerIndex] + val upperValue: Long = sortedDurationNanos[upperIndex] + + return floor(lowerValue + (upperValue - lowerValue) * interpolationFactor).toLong() + } + } +} diff --git a/core-util/src/main/java/org/signal/core/util/InputStreamExtensions.kt b/core-util/src/main/java/org/signal/core/util/InputStreamExtensions.kt new file mode 100644 index 0000000000..2c567d556d --- /dev/null +++ b/core-util/src/main/java/org/signal/core/util/InputStreamExtensions.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.util + +import java.io.IOException +import java.io.InputStream +import kotlin.jvm.Throws + +/** + * Reads the entire stream into a [ByteArray]. + */ +@Throws(IOException::class) +fun InputStream.readFully(): ByteArray { + return StreamUtil.readFully(this) +} + +/** + * Fills reads data from the stream into the [buffer] until it is full. + * Throws an [IOException] if the stream doesn't have enough data to fill the buffer. + */ +@Throws(IOException::class) +fun InputStream.readFully(buffer: ByteArray) { + return StreamUtil.readFully(this, buffer) +} + +/** + * Reads the specified number of bytes from the stream and returns it as a [ByteArray]. + * Throws an [IOException] if the stream doesn't have that many bytes. + */ +@Throws(IOException::class) +fun InputStream.readNBytesOrThrow(length: Int): ByteArray { + val buffer: ByteArray = ByteArray(length) + this.readFully(buffer) + return buffer +} 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 ce7afe1bc2..3af6f8e298 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 @@ -109,6 +109,15 @@ object SqlUtil { } } + /** + * For tables that have an autoincrementing primary key, this will reset the key to start back at 1. + * IMPORTANT: This is quite dangerous! Only do this if you're effectively resetting the entire database. + */ + @JvmStatic + fun resetAutoIncrementValue(db: SupportSQLiteDatabase, targetTable: String) { + db.execSQL("DELETE FROM sqlite_sequence WHERE name=?", arrayOf(targetTable)) + } + @JvmStatic fun isEmpty(db: SupportSQLiteDatabase, table: String): Boolean { db.query("SELECT COUNT(*) FROM $table", null).use { cursor -> @@ -388,36 +397,30 @@ object SqlUtil { val builder = StringBuilder() builder.append("INSERT INTO ").append(tableName).append(" (") - for (i in columns.indices) { - builder.append(columns[i]) - if (i < columns.size - 1) { - builder.append(", ") - } - } + val columnString = columns.joinToString(separator = ", ") + builder.append(columnString) builder.append(") VALUES ") - val placeholder = StringBuilder() - placeholder.append("(") - - for (i in columns.indices) { - placeholder.append("?") - if (i < columns.size - 1) { - placeholder.append(", ") + val placeholders = contentValues + .map { values -> + columns + .map { column -> + if (values[column] != null) { + if (values[column] is ByteArray) { + "X'${Hex.toStringCondensed(values[column] as ByteArray).uppercase()}'" + } else { + "?" + } + } else { + "null" + } + } + .joinToString(separator = ", ", prefix = "(", postfix = ")") } - } + .joinToString(separator = ", ") - placeholder.append(")") - - var i = 0 - val len = contentValues.size - while (i < len) { - builder.append(placeholder) - if (i < len - 1) { - builder.append(", ") - } - i++ - } + builder.append(placeholders) val query = builder.toString() val args: MutableList = mutableListOf() @@ -425,7 +428,10 @@ object SqlUtil { for (values in contentValues) { for (column in columns) { val value = values[column] - args += if (value != null) values[column].toString() else "null" + + if (value != null && value !is ByteArray) { + args += value.toString() + } } } 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 5e6cbb3223..49fe18533c 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 @@ -42,3 +42,15 @@ fun String?.emptyIfNull(): String { fun String.toSingleLine(): String { return this.trimIndent().split("\n").joinToString(separator = " ") } + +fun String?.nullIfEmpty(): String? { + return this?.ifEmpty { + null + } +} + +fun String?.nullIfBlank(): String? { + return this?.ifBlank { + 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 368a3e39d4..e8eacf716d 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 @@ -229,6 +229,24 @@ public final class SqlUtilTest { assertArrayEquals(new String[] { "1", "2" }, output.get(0).getWhereArgs()); } + @Test + public void buildBulkInsert_single_singleBatch_containsNulls() { + List contentValues = new ArrayList<>(); + + ContentValues cv1 = new ContentValues(); + cv1.put("a", 1); + cv1.put("b", 2); + cv1.put("c", (String) null); + + contentValues.add(cv1); + + List output = SqlUtil.buildBulkInsert("mytable", new String[] { "a", "b", "c"}, contentValues); + + assertEquals(1, output.size()); + assertEquals("INSERT INTO mytable (a, b, c) VALUES (?, ?, null)", output.get(0).getWhere()); + assertArrayEquals(new String[] { "1", "2" }, output.get(0).getWhereArgs()); + } + @Test public void buildBulkInsert_multiple_singleBatch() { List contentValues = new ArrayList<>(); diff --git a/glide-webp/app/src/main/java/org/signal/glide/webp/app/SampleAppGlideModule.kt b/glide-webp/app/src/main/java/org/signal/glide/webp/app/SampleAppGlideModule.kt index 56420b5b2b..83a38e13d0 100644 --- a/glide-webp/app/src/main/java/org/signal/glide/webp/app/SampleAppGlideModule.kt +++ b/glide-webp/app/src/main/java/org/signal/glide/webp/app/SampleAppGlideModule.kt @@ -14,7 +14,11 @@ import org.signal.core.util.logging.Log @GlideModule class SampleAppGlideModule : AppGlideModule() { + companion object { + private val TAG = Log.tag(SampleAppGlideModule::class.java) + } + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { - Log.e("SPIDERMAN", "AppModule - registerComponents") + Log.e(TAG, "AppModule - registerComponents") } }