mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Setup backupV2 infrastructure and testing.
Co-authored-by: Clark Chen <clark@signal.org>
This commit is contained in:
committed by
Cody Henthorne
parent
feb74d90f6
commit
b540b5813e
@@ -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<String, List<Map<String, Any?>>>
|
||||
|
||||
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<String, Set<String>> = mapOf(
|
||||
RecipientTable.TABLE_NAME to setOf(RecipientTable.STORAGE_SERVICE_ID)
|
||||
)
|
||||
|
||||
/** Tables we don't need to check equality of */
|
||||
private val IGNORED_TABLES: Set<String> = 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<BodyRangeList.BodyRange> = 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<Map<String, Any?>> = expected[table]!!
|
||||
val actualTable: List<Map<String, Any?>> = 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<Map<String, Any?>> = expectedTable.withoutExcludedColumns(IGNORED_COLUMNS[table])
|
||||
val actualFiltered: List<Map<String, Any?>> = 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<Map<String, Any?>>, actualRows: List<Map<String, Any?>>): 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<Map<String, Any?>>, actualRows: List<Map<String, Any?>>): 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<Map<String, Any?>>.withoutExcludedColumns(ignored: Set<String>?): List<Map<String, Any?>> {
|
||||
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<Map<String, Any?>> {
|
||||
return this
|
||||
.select()
|
||||
.from(table)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
val map: MutableMap<String, Any?> = 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
|
||||
)
|
||||
}
|
||||
@@ -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<Long, RecipientId>()
|
||||
val chatIdToLocalThreadId = HashMap<Long, Long>()
|
||||
val chatIdToLocalRecipientId = HashMap<Long, RecipientId>()
|
||||
val chatIdToBackupRecipientId = HashMap<Long, Long>()
|
||||
}
|
||||
@@ -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<ChatItem>, 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<ChatItem> = 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<Long, BackupMessageRecord> = linkedMapOf()
|
||||
|
||||
for (i in 0 until batchSize) {
|
||||
if (cursor.moveToNext()) {
|
||||
val record = cursor.toBackupMessageRecord()
|
||||
records[record.id] = record
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val reactionsById: Map<Long, List<ReactionRecord>> = SignalDatabase.reactions.getReactionsForMessages(records.keys)
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>> = 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<GroupReceiptTable.GroupReceiptInfo>?): 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<ReactionRecord>?): 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<BackupBodyRange> {
|
||||
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<ReactionRecord>?.toBackupReactions(): List<Reaction> {
|
||||
return this
|
||||
?.map {
|
||||
Reaction(
|
||||
emoji = it.emoji,
|
||||
authorId = it.author.toLong(),
|
||||
sentTimestamp = it.dateSent,
|
||||
receivedTimestamp = it.dateReceived
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
private fun BackupMessageRecord.toBackupSendStatus(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): List<SendStatus> {
|
||||
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<GroupReceiptTable.GroupReceiptInfo>.toBackupSendStatus(networkFailureRecipientIds: Set<Long>, identityMismatchRecipientIds: Set<Long>): List<SendStatus> {
|
||||
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<Long> {
|
||||
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<Long> {
|
||||
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<Long>,
|
||||
val identityMismatchRecipientIds: Set<Long>,
|
||||
val baseType: Long
|
||||
)
|
||||
}
|
||||
@@ -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<ContentValues> {
|
||||
val reactions: List<Reaction> = 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<ContentValues> {
|
||||
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<BodyRange>.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<ContentValues> = mutableListOf(),
|
||||
val reactions: MutableList<ContentValues> = mutableListOf(),
|
||||
val groupReceipts: MutableList<ContentValues> = mutableListOf()
|
||||
) {
|
||||
val size: Int
|
||||
get() = listOf(messages.size, reactions.size, groupReceipts.size).max()
|
||||
}
|
||||
}
|
||||
@@ -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<BackupRecipient> {
|
||||
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<RecipientId> = 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<BackupRecipient>, 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<BackupRecipient>, 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())
|
||||
}
|
||||
}
|
||||
@@ -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<Chat>, 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Frame> {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE))
|
||||
val state: State<ScreenState> = _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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<Long>): Map<Long, List<GroupReceiptInfo>> {
|
||||
if (ids.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val messageIdsToGroupReceipts: MutableMap<Long, MutableList<GroupReceiptInfo>> = mutableMapOf()
|
||||
|
||||
val args: List<Array<String>> = 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,
|
||||
|
||||
@@ -1770,7 +1770,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
return threads.getOrCreateThreadIdFor(recipient)
|
||||
}
|
||||
|
||||
private fun rawQueryWithAttachments(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0): Cursor {
|
||||
fun rawQueryWithAttachments(where: String, arguments: Array<String>?, reverse: Boolean = false, limit: Long = 0): Cursor {
|
||||
return rawQueryWithAttachments(MMS_PROJECTION_WITH_ATTACHMENTS, where, arguments, reverse, limit)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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<Badge> {
|
||||
var badgeList: BadgeList? = null
|
||||
if (serializedBadgeList != null) {
|
||||
try {
|
||||
badgeList = BadgeList.ADAPTER.decode(serializedBadgeList)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
val badges: List<Badge>
|
||||
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<RecipientRecord>, 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)
|
||||
|
||||
@@ -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<Badge> {
|
||||
var badgeList: BadgeList? = null
|
||||
if (serializedBadgeList != null) {
|
||||
try {
|
||||
badgeList = BadgeList.ADAPTER.decode(serializedBadgeList)
|
||||
} catch (e: InvalidProtocolBufferException) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
val badges: List<Badge>
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<SendMessageResult> successes = Stream.of(results).filter(result -> result.getSuccess() != null).toList();
|
||||
List<Pair<RecipientId, Boolean>> successUnidentifiedStatus = Stream.of(successes).map(result -> new Pair<>(accessList.requireIdByAddress(result.getAddress()), result.getSuccess().isUnidentified())).toList();
|
||||
Set<RecipientId> successIds = Stream.of(successUnidentifiedStatus).map(Pair::first).collect(Collectors.toSet());
|
||||
Set<NetworkFailure> resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId(context))).collect(Collectors.toSet());
|
||||
Set<IdentityKeyMismatch> resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId(context))).collect(Collectors.toSet());
|
||||
Set<NetworkFailure> resolvedNetworkFailures = Stream.of(existingNetworkFailures).filter(failure -> successIds.contains(failure.getRecipientId())).collect(Collectors.toSet());
|
||||
Set<IdentityKeyMismatch> resolvedIdentityFailures = Stream.of(existingIdentityMismatches).filter(failure -> successIds.contains(failure.getRecipientId())).collect(Collectors.toSet());
|
||||
List<RecipientId> unregisteredRecipients = Stream.of(results).filter(SendMessageResult::isUnregisteredFailure).map(result -> RecipientId.from(result.getAddress())).toList();
|
||||
List<RecipientId> invalidPreKeyRecipients = Stream.of(results).filter(SendMessageResult::isInvalidPreKeyFailure).map(result -> RecipientId.from(result.getAddress())).toList();
|
||||
Set<RecipientId> 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<RecipientId> mismatchRecipientIds = Stream.of(existingIdentityMismatches)
|
||||
.map(mismatch -> mismatch.getRecipientId(context))
|
||||
.map(mismatch -> mismatch.getRecipientId())
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
RetrieveProfileJob.enqueue(mismatchRecipientIds);
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -200,6 +200,7 @@ public final class FeatureFlags {
|
||||
@SuppressWarnings("MismatchedQueryAndUpdateOfCollection")
|
||||
@VisibleForTesting
|
||||
static final Map<String, Object> FORCED_VALUES = new HashMap<String, Object>() {{
|
||||
put(INTERNAL_USER, true);
|
||||
}};
|
||||
|
||||
/**
|
||||
|
||||
541
app/src/main/protowire/Backup.proto
Normal file
541
app/src/main/protowire/Backup.proto
Normal file
@@ -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;
|
||||
}
|
||||
@@ -612,6 +612,9 @@
|
||||
<action
|
||||
android:id="@+id/action_internalSettingsFragment_to_terminalDonationConfigurationFragment"
|
||||
app:destination="@id/terminalDonationConfigurationFragment" />
|
||||
<action
|
||||
android:id="@+id/action_internalSettingsFragment_to_internalBackupPlaygroundFragment"
|
||||
app:destination="@id/internalBackupPlaygroundFragment" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
@@ -659,6 +662,11 @@
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.internal.conversation.test.InternalConversationTestFragment"
|
||||
android:label="internal_conversation_test_fragment" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/internalBackupPlaygroundFragment"
|
||||
android:name="org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundFragment"
|
||||
android:label="internal_backup_playground_fragment" />
|
||||
|
||||
<!-- endregion -->
|
||||
|
||||
<!-- App updates -->
|
||||
|
||||
108
core-util/src/main/java/org/signal/core/util/EventTimer.kt
Normal file
108
core-util/src/main/java/org/signal/core/util/EventTimer.kt
Normal file
@@ -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<String, MutableList<Long>> = 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<String, EventMetrics> = durationsByGroup
|
||||
.mapValues { entry ->
|
||||
val sorted: List<Long> = entry.value.sorted()
|
||||
|
||||
EventMetrics(
|
||||
totalTime = sorted.sum().nanoseconds.toDouble(DurationUnit.MILLISECONDS),
|
||||
eventCount = sorted.size,
|
||||
sortedDurationNanos = sorted
|
||||
)
|
||||
}
|
||||
|
||||
return EventTimerResults(data)
|
||||
}
|
||||
|
||||
class EventTimerResults(data: Map<String, EventMetrics>) : Map<String, EventMetrics> 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<Long>
|
||||
) {
|
||||
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<String> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> contentValues = new ArrayList<>();
|
||||
|
||||
ContentValues cv1 = new ContentValues();
|
||||
cv1.put("a", 1);
|
||||
cv1.put("b", 2);
|
||||
cv1.put("c", (String) null);
|
||||
|
||||
contentValues.add(cv1);
|
||||
|
||||
List<SqlUtil.Query> 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> contentValues = new ArrayList<>();
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user