mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Update to latest Backup.proto and fix various backup bugs.
This commit is contained in:
committed by
mtang-signal
parent
e2e6a73e8d
commit
5ffb7b07da
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.github.difflib.DiffUtils
|
||||
import com.github.difflib.UnifiedDiffUtils
|
||||
import junit.framework.Assert.assertTrue
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.libsignal.messagebackup.ComparableBackup
|
||||
import org.signal.libsignal.messagebackup.MessageBackup
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.backup.v2.proto.Frame
|
||||
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.KeyValueDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ArchiveImportExportTests {
|
||||
|
||||
companion object {
|
||||
const val TAG = "ImportExport"
|
||||
const val TESTS_FOLDER = "backupTests"
|
||||
|
||||
val SELF_ACI = ServiceId.ACI.from(UUID(100, 100))
|
||||
val SELF_PNI = ServiceId.PNI.from(UUID(101, 101))
|
||||
val SELF_E164 = "+10000000000"
|
||||
val SELF_PROFILE_KEY: ByteArray = Base64.decode("YQKRq+3DQklInaOaMcmlzZnN0m/1hzLiaONX7gB12dg=")
|
||||
val MASTER_KEY = Base64.decode("sHuBMP4ToZk4tcNU+S8eBUeCt8Am5EZnvuqTBJIR4Do")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun all() {
|
||||
runTests()
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun accountData() {
|
||||
runTests { it.startsWith("account_data_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun recipientContacts() {
|
||||
runTests { it.startsWith("recipient_contacts_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun recipientDistributionLists() {
|
||||
runTests { it.startsWith("recipient_distribution_list_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun recipientGroups() {
|
||||
runTests { it.startsWith("recipient_groups_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageTextOnly() {
|
||||
runTests { it.startsWith("chat_standard_message_text_only_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageFormattedText() {
|
||||
runTests { it.startsWith("chat_standard_message_formatted_text_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageLongText() {
|
||||
runTests { it.startsWith("chat_standard_message_long_text_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageStandardAttachments() {
|
||||
runTests { it.startsWith("chat_standard_message_standard_attachments_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatStandardMessageSpecialAttachments() {
|
||||
runTests { it.startsWith("chat_standard_message_special_attachments_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatSimpleUpdates() {
|
||||
runTests { it.startsWith("chat_simple_updates_") }
|
||||
}
|
||||
|
||||
@Ignore("Just for debugging")
|
||||
@Test
|
||||
fun chatContactMessage() {
|
||||
runTests { it.startsWith("chat_contact_message_") }
|
||||
}
|
||||
|
||||
private fun runTests(predicate: (String) -> Boolean = { true }) {
|
||||
val testFiles = InstrumentationRegistry.getInstrumentation().context.resources.assets.list(TESTS_FOLDER)!!.filter(predicate)
|
||||
val results: MutableList<TestResult> = mutableListOf()
|
||||
|
||||
Log.d(TAG, "About to run ${testFiles.size} tests.")
|
||||
|
||||
for (filename in testFiles) {
|
||||
Log.d(TAG, "> $filename")
|
||||
val startTime = System.currentTimeMillis()
|
||||
val result = test(filename)
|
||||
results += result
|
||||
|
||||
if (result is TestResult.Success) {
|
||||
Log.d(TAG, " \uD83D\uDFE2 Passed in ${System.currentTimeMillis() - startTime} ms")
|
||||
} else {
|
||||
Log.d(TAG, " \uD83D\uDD34 Failed in ${System.currentTimeMillis() - startTime} ms")
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
.filterIsInstance<TestResult.Failure>()
|
||||
.forEach {
|
||||
Log.e(TAG, "Failure: ${it.name}\n${it.message}")
|
||||
Log.e(TAG, "----------------------------------")
|
||||
Log.e(TAG, "----------------------------------")
|
||||
Log.e(TAG, "----------------------------------")
|
||||
}
|
||||
|
||||
if (results.any { it is TestResult.Failure }) {
|
||||
val successCount = results.count { it is TestResult.Success }
|
||||
val failingTestNames = results.filterIsInstance<TestResult.Failure>().joinToString(separator = "\n") { " \uD83D\uDD34 ${it.name}" }
|
||||
val message = "Some tests failed! Only $successCount/${results.size} passed. Failure details are above. Failing tests:\n$failingTestNames"
|
||||
|
||||
Log.d(TAG, message)
|
||||
throw AssertionError(message)
|
||||
} else {
|
||||
Log.d(TAG, "All ${results.size} tests passed!")
|
||||
}
|
||||
}
|
||||
|
||||
private fun test(filename: String): TestResult {
|
||||
resetAllData()
|
||||
|
||||
val inputFileBytes: ByteArray = InstrumentationRegistry.getInstrumentation().context.resources.assets.open("$TESTS_FOLDER/$filename").readFully(true)
|
||||
|
||||
val importResult = import(inputFileBytes)
|
||||
assertTrue(importResult is ImportResult.Success)
|
||||
val success = importResult as ImportResult.Success
|
||||
|
||||
val generatedBackupData = BackupRepository.debugExport(plaintext = true, currentTime = success.backupTime)
|
||||
checkEquivalent(filename, inputFileBytes, generatedBackupData)?.let { return it }
|
||||
|
||||
// Validator expects encrypted data, so we have to export again with encryption to validate
|
||||
val encryptedBackupData = BackupRepository.debugExport(plaintext = false, currentTime = success.backupTime)
|
||||
assertPassesValidator(filename, encryptedBackupData)?.let { return it }
|
||||
|
||||
return TestResult.Success(filename)
|
||||
}
|
||||
|
||||
private fun resetAllData() {
|
||||
// Need to delete these first to prevent foreign key crash
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM ${DistributionListTables.ListTable.TABLE_NAME}")
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM ${DistributionListTables.MembershipTable.TABLE_NAME}")
|
||||
|
||||
SqlUtil.getAllTables(SignalDatabase.rawDatabase)
|
||||
.filterNot { it.contains("sqlite") || it.contains("fts") || it.startsWith("emoji_search_") } // If we delete these we'll corrupt the DB
|
||||
.sorted()
|
||||
.forEach { table ->
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM $table")
|
||||
SqlUtil.resetAutoIncrementValue(SignalDatabase.rawDatabase, table)
|
||||
}
|
||||
|
||||
AppDependencies.recipientCache.clear()
|
||||
AppDependencies.recipientCache.clearSelf()
|
||||
RecipientId.clearCache()
|
||||
|
||||
KeyValueDatabase.getInstance(AppDependencies.application).clear()
|
||||
SignalStore.resetCache()
|
||||
|
||||
SignalStore.svr.setMasterKey(MasterKey(MASTER_KEY), "1234")
|
||||
SignalStore.account.setE164(SELF_E164)
|
||||
SignalStore.account.setAci(SELF_ACI)
|
||||
SignalStore.account.setPni(SELF_PNI)
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
SignalStore.backup.backupTier = MessageBackupTier.PAID
|
||||
}
|
||||
|
||||
private fun import(importData: ByteArray): ImportResult {
|
||||
return BackupRepository.import(
|
||||
length = importData.size.toLong(),
|
||||
inputStreamFactory = { ByteArrayInputStream(importData) },
|
||||
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, ProfileKey(SELF_PROFILE_KEY)),
|
||||
plaintext = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertPassesValidator(testName: String, generatedBackupData: ByteArray): TestResult.Failure? {
|
||||
try {
|
||||
BackupRepository.validate(
|
||||
length = generatedBackupData.size.toLong(),
|
||||
inputStreamFactory = { ByteArrayInputStream(generatedBackupData) },
|
||||
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, ProfileKey(SELF_PROFILE_KEY))
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return TestResult.Failure(testName, "Generated backup failed validation: ${e.message}")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun checkEquivalent(testName: String, import: ByteArray, export: ByteArray): TestResult.Failure? {
|
||||
val importComparable = try {
|
||||
ComparableBackup.readUnencrypted(MessageBackup.Purpose.REMOTE_BACKUP, import.inputStream(), import.size.toLong())
|
||||
} catch (e: Exception) {
|
||||
return TestResult.Failure(testName, "Imported backup hit a validation error: ${e.message}")
|
||||
}
|
||||
|
||||
val exportComparable = try {
|
||||
ComparableBackup.readUnencrypted(MessageBackup.Purpose.REMOTE_BACKUP, export.inputStream(), import.size.toLong())
|
||||
} catch (e: Exception) {
|
||||
return TestResult.Failure(testName, "Exported backup hit a validation error: ${e.message}")
|
||||
}
|
||||
|
||||
if (importComparable.unknownFieldMessages.isNotEmpty()) {
|
||||
return TestResult.Failure(testName, "Imported backup contains unknown fields: ${importComparable.unknownFieldMessages}")
|
||||
}
|
||||
|
||||
if (exportComparable.unknownFieldMessages.isNotEmpty()) {
|
||||
return TestResult.Failure(testName, "Imported backup contains unknown fields: ${importComparable.unknownFieldMessages}")
|
||||
}
|
||||
|
||||
val canonicalImport = importComparable.comparableString
|
||||
val canonicalExport = exportComparable.comparableString
|
||||
|
||||
if (canonicalImport != canonicalExport) {
|
||||
val importLines = canonicalImport.lines()
|
||||
val exportLines = canonicalExport.lines()
|
||||
|
||||
val patch = DiffUtils.diff(importLines, exportLines)
|
||||
val diff = UnifiedDiffUtils.generateUnifiedDiff("Import", "Export", importLines, patch, 3).joinToString(separator = "\n")
|
||||
|
||||
val importFrames = import.toFrames()
|
||||
val exportFrames = export.toFrames()
|
||||
|
||||
val importGroupFramesByMasterKey = importFrames.mapNotNull { it.recipient?.group }.associateBy { it.masterKey }
|
||||
val exportGroupFramesByMasterKey = exportFrames.mapNotNull { it.recipient?.group }.associateBy { it.masterKey }
|
||||
|
||||
val groupErrorMessage = StringBuilder()
|
||||
|
||||
for ((importKey, importValue) in importGroupFramesByMasterKey) {
|
||||
if (exportGroupFramesByMasterKey[importKey]?.let { it.snapshot != importValue.snapshot } == true) {
|
||||
groupErrorMessage.append("[$importKey] Snapshot mismatch.\nImport:\n${importValue}\n\nExport:\n${exportGroupFramesByMasterKey[importKey]}\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return TestResult.Failure(testName, "Imported backup does not match exported backup. Diff:\n$diff\n$groupErrorMessage")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun ByteArray.toFrames(): List<Frame> {
|
||||
return PlainTextBackupReader(this.inputStream(), this.size.toLong()).use { it.asSequence().toList() }
|
||||
}
|
||||
|
||||
private sealed class TestResult(val name: String) {
|
||||
class Success(name: String) : TestResult(name)
|
||||
class Failure(name: String, val message: String) : TestResult(name)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,118 +0,0 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.github.difflib.DiffUtils
|
||||
import com.github.difflib.UnifiedDiffUtils
|
||||
import junit.framework.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.libsignal.messagebackup.ComparableBackup
|
||||
import org.signal.libsignal.messagebackup.MessageBackup
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class ImportExportTestSuite(private val path: String) {
|
||||
companion object {
|
||||
val SELF_ACI = ServiceId.ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
|
||||
val SELF_PNI = ServiceId.PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
|
||||
const val SELF_E164 = "+10000000000"
|
||||
val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32))
|
||||
val MASTER_KEY = Base64.decode("sHuBMP4ToZk4tcNU+S8eBUeCt8Am5EZnvuqTBJIR4Do")
|
||||
|
||||
const val TESTS_FOLDER = "backupTests"
|
||||
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "{0}")
|
||||
fun data(): Collection<Array<String>> {
|
||||
val testFiles = InstrumentationRegistry.getInstrumentation().context.resources.assets.list(TESTS_FOLDER)!!
|
||||
return testFiles
|
||||
.map { arrayOf(it) }
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.svr.setMasterKey(MasterKey(MASTER_KEY), "1234")
|
||||
SignalStore.account.setE164(SELF_E164)
|
||||
SignalStore.account.setAci(SELF_ACI)
|
||||
SignalStore.account.setPni(SELF_PNI)
|
||||
SignalStore.account.generateAciIdentityKeyIfNecessary()
|
||||
SignalStore.account.generatePniIdentityKeyIfNecessary()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBinProto() {
|
||||
val binProtoBytes: ByteArray = InstrumentationRegistry.getInstrumentation().context.resources.assets.open("${TESTS_FOLDER}/$path").use {
|
||||
StreamUtil.readFully(it)
|
||||
}
|
||||
val importResult = import(binProtoBytes)
|
||||
assertTrue(importResult is ImportResult.Success)
|
||||
val success = importResult as ImportResult.Success
|
||||
|
||||
val generatedBackupData = BackupRepository.debugExport(plaintext = true, currentTime = success.backupTime)
|
||||
assertEquivalent(binProtoBytes, generatedBackupData)
|
||||
|
||||
// Validator expects encrypted data, so we have to export again with encryption to validate
|
||||
val encryptedBackupData = BackupRepository.debugExport(plaintext = false, currentTime = success.backupTime)
|
||||
assertPassesValidator(encryptedBackupData)
|
||||
}
|
||||
|
||||
private fun import(importData: ByteArray): ImportResult {
|
||||
return BackupRepository.import(
|
||||
length = importData.size.toLong(),
|
||||
inputStreamFactory = { ByteArrayInputStream(importData) },
|
||||
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY),
|
||||
plaintext = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertPassesValidator(generatedBackupData: ByteArray) {
|
||||
BackupRepository.validate(
|
||||
length = generatedBackupData.size.toLong(),
|
||||
inputStreamFactory = { ByteArrayInputStream(generatedBackupData) },
|
||||
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY)
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertEquivalent(import: ByteArray, export: ByteArray) {
|
||||
val importComparable = ComparableBackup.readUnencrypted(MessageBackup.Purpose.REMOTE_BACKUP, import.inputStream(), import.size.toLong())
|
||||
val exportComparable = ComparableBackup.readUnencrypted(MessageBackup.Purpose.REMOTE_BACKUP, export.inputStream(), import.size.toLong())
|
||||
|
||||
if (importComparable.unknownFieldMessages.isNotEmpty()) {
|
||||
throw AssertionError("Imported backup contains unknown fields: ${importComparable.unknownFieldMessages}")
|
||||
}
|
||||
|
||||
if (exportComparable.unknownFieldMessages.isNotEmpty()) {
|
||||
throw AssertionError("Imported backup contains unknown fields: ${importComparable.unknownFieldMessages}")
|
||||
}
|
||||
|
||||
val canonicalImport = importComparable.comparableString
|
||||
val canonicalExport = exportComparable.comparableString
|
||||
|
||||
if (canonicalImport != canonicalExport) {
|
||||
val importLines = canonicalImport.lines()
|
||||
val exportLines = canonicalExport.lines()
|
||||
|
||||
val patch = DiffUtils.diff(importLines, exportLines)
|
||||
val diff = UnifiedDiffUtils.generateUnifiedDiff("Import", "Export", importLines, patch, 3).joinToString(separator = "\n")
|
||||
|
||||
throw AssertionError("Imported backup does not match exported backup. Diff:\n$diff")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1103,8 +1103,8 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
init {
|
||||
// Need to delete these first to prevent foreign key crash
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM distribution_list")
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM distribution_list_member")
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM ${DistributionListTables.ListTable.TABLE_NAME}")
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM ${DistributionListTables.MembershipTable.TABLE_NAME}")
|
||||
|
||||
SqlUtil.getAllTables(SignalDatabase.rawDatabase)
|
||||
.filterNot { it.contains("sqlite") || it.contains("fts") || it.startsWith("emoji_search_") } // If we delete these we'll corrupt the DB
|
||||
|
||||
@@ -49,13 +49,14 @@ class ArchivedAttachment : Attachment {
|
||||
stickerLocator: StickerLocator?,
|
||||
gif: Boolean,
|
||||
quote: Boolean,
|
||||
uuid: UUID?
|
||||
uuid: UUID?,
|
||||
fileName: String?
|
||||
) : super(
|
||||
contentType = contentType ?: "",
|
||||
quote = quote,
|
||||
transferState = AttachmentTable.TRANSFER_NEEDS_RESTORE,
|
||||
size = size,
|
||||
fileName = null,
|
||||
fileName = fileName,
|
||||
cdn = Cdn.fromCdnNumber(cdn),
|
||||
remoteLocation = cdnKey,
|
||||
remoteKey = Base64.encodeWithoutPadding(key),
|
||||
|
||||
@@ -46,7 +46,7 @@ enum class Cdn(private val value: Int) {
|
||||
0 -> CDN_0
|
||||
2 -> CDN_2
|
||||
3 -> CDN_3
|
||||
else -> throw UnsupportedOperationException()
|
||||
else -> throw UnsupportedOperationException("Invalid CDN number: $cdnNumber")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class DatabaseAttachment : Attachment {
|
||||
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
|
||||
uuid: UUID?
|
||||
) : super(
|
||||
contentType = contentType!!,
|
||||
contentType = contentType,
|
||||
transferState = transferProgress,
|
||||
size = size,
|
||||
fileName = fileName,
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.UUID
|
||||
* quote them and know their contentType even though the media has been deleted.
|
||||
*/
|
||||
class TombstoneAttachment : Attachment {
|
||||
constructor(contentType: String, quote: Boolean) : super(
|
||||
constructor(contentType: String?, quote: Boolean) : super(
|
||||
contentType = contentType,
|
||||
quote = quote,
|
||||
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
|
||||
|
||||
@@ -14,7 +14,7 @@ class UriAttachment : Attachment {
|
||||
|
||||
constructor(
|
||||
uri: Uri,
|
||||
contentType: String,
|
||||
contentType: String?,
|
||||
transferState: Int,
|
||||
size: Long,
|
||||
fileName: String?,
|
||||
@@ -50,7 +50,7 @@ class UriAttachment : Attachment {
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
dataUri: Uri,
|
||||
contentType: String,
|
||||
contentType: String?,
|
||||
transferState: Int,
|
||||
size: Long,
|
||||
width: Int,
|
||||
|
||||
@@ -211,7 +211,7 @@ object BackupRepository {
|
||||
)
|
||||
}
|
||||
|
||||
val exportState = ExportState(backupTime = currentTime, allowMediaBackup = SignalStore.backup.backsUpMedia)
|
||||
val exportState = ExportState(backupTime = currentTime, mediaBackupEnabled = SignalStore.backup.backsUpMedia)
|
||||
|
||||
writer.use {
|
||||
writer.write(
|
||||
@@ -302,13 +302,15 @@ object BackupRepository {
|
||||
// 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()
|
||||
SignalDatabase.attachments.clearAllDataForBackupRestore()
|
||||
SignalDatabase.stickers.clearAllDataForBackupRestore()
|
||||
SignalDatabase.reactions.clearAllDataForBackupRestore()
|
||||
SignalDatabase.inAppPayments.clearAllDataForBackupRestore()
|
||||
SignalDatabase.chatColors.clearAllDataForBackupRestore()
|
||||
|
||||
// Add back self after clearing data
|
||||
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
|
||||
@@ -953,7 +955,7 @@ data class ArchivedMediaObject(val mediaId: String, val cdn: Int)
|
||||
|
||||
data class BackupDirectories(val backupDir: String, val mediaDir: String)
|
||||
|
||||
class ExportState(val backupTime: Long, val allowMediaBackup: Boolean) {
|
||||
class ExportState(val backupTime: Long, val mediaBackupEnabled: Boolean) {
|
||||
val recipientIds: MutableSet<Long> = hashSetOf()
|
||||
val threadIds: MutableSet<Long> = hashSetOf()
|
||||
val localToRemoteCustomChatColors: MutableMap<Long, Int> = hashMapOf()
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.database.ChatColorsTable
|
||||
|
||||
fun ChatColorsTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.deleteAll(ChatColorsTable.TABLE_NAME)
|
||||
}
|
||||
@@ -131,7 +131,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
}
|
||||
}
|
||||
|
||||
val reactionsById: Map<Long, List<ReactionRecord>> = SignalDatabase.reactions.getReactionsForMessages(records.keys)
|
||||
val reactionsById: Map<Long, List<ReactionRecord>> = SignalDatabase.reactions.getReactionsForMessages(records.keys).map { entry -> entry.key to entry.value.sortedBy { it.dateReceived } }.toMap()
|
||||
val mentionsById: Map<Long, List<Mention>> = SignalDatabase.mentions.getMentionsForMessages(records.keys)
|
||||
val attachmentsById: Map<Long, List<DatabaseAttachment>> = SignalDatabase.attachments.getAttachmentsForMessages(records.keys)
|
||||
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys)
|
||||
@@ -757,28 +757,28 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
|
||||
private fun DatabaseAttachment.toBackupAttachment(): MessageAttachment {
|
||||
val builder = FilePointer.Builder()
|
||||
builder.contentType = contentType
|
||||
builder.incrementalMac = incrementalDigest?.toByteString()
|
||||
builder.incrementalMacChunkSize = incrementalMacChunkSize
|
||||
builder.fileName = fileName
|
||||
builder.width = width
|
||||
builder.height = height
|
||||
builder.caption = caption
|
||||
builder.blurHash = blurHash?.hash
|
||||
builder.contentType = this.contentType?.takeUnless { it.isBlank() }
|
||||
builder.incrementalMac = this.incrementalDigest?.toByteString()
|
||||
builder.incrementalMacChunkSize = this.incrementalMacChunkSize.takeIf { it > 0 }
|
||||
builder.fileName = this.fileName
|
||||
builder.width = this.width.takeUnless { it == 0 }
|
||||
builder.height = this.height.takeUnless { it == 0 }
|
||||
builder.caption = this.caption
|
||||
builder.blurHash = this.blurHash?.hash
|
||||
|
||||
if (remoteKey.isNullOrBlank() || remoteDigest == null || size == 0L) {
|
||||
if (this.remoteKey.isNullOrBlank() || this.remoteDigest == null || this.size == 0L) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
} else {
|
||||
if (archiveMedia) {
|
||||
builder.backupLocator = FilePointer.BackupLocator(
|
||||
mediaName = archiveMediaName ?: this.getMediaName().toString(),
|
||||
cdnNumber = if (archiveMediaName != null) archiveCdn else Cdn.CDN_3.cdnNumber, // TODO (clark): Update when new proto with optional cdn is landed
|
||||
mediaName = this.archiveMediaName ?: this.getMediaName().toString(),
|
||||
cdnNumber = if (this.archiveMediaName != null) this.archiveCdn else Cdn.CDN_3.cdnNumber, // TODO (clark): Update when new proto with optional cdn is landed
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size,
|
||||
digest = remoteDigest.toByteString()
|
||||
size = this.size.toInt(),
|
||||
digest = this.remoteDigest.toByteString()
|
||||
)
|
||||
} else {
|
||||
if (remoteLocation.isNullOrBlank()) {
|
||||
if (this.remoteLocation.isNullOrBlank()) {
|
||||
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
|
||||
} else {
|
||||
builder.attachmentLocator = FilePointer.AttachmentLocator(
|
||||
@@ -787,7 +787,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
uploadTimestamp = this.uploadTimestamp,
|
||||
key = Base64.decode(remoteKey).toByteString(),
|
||||
size = this.size.toInt(),
|
||||
digest = remoteDigest.toByteString()
|
||||
digest = this.remoteDigest.toByteString()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -795,16 +795,16 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
return MessageAttachment(
|
||||
pointer = builder.build(),
|
||||
wasDownloaded = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE,
|
||||
flag = if (voiceNote) {
|
||||
flag = if (this.voiceNote) {
|
||||
MessageAttachment.Flag.VOICE_MESSAGE
|
||||
} else if (videoGif) {
|
||||
} else if (this.videoGif) {
|
||||
MessageAttachment.Flag.GIF
|
||||
} else if (borderless) {
|
||||
} else if (this.borderless) {
|
||||
MessageAttachment.Flag.BORDERLESS
|
||||
} else {
|
||||
MessageAttachment.Flag.NONE
|
||||
},
|
||||
clientUuid = uuid?.let { UuidUtil.toByteString(uuid) }
|
||||
clientUuid = this.uuid?.let { UuidUtil.toByteString(uuid) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -873,11 +873,18 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
}
|
||||
|
||||
return decoded.ranges.map {
|
||||
val mention = it.mentionUuid?.let { uuid -> UuidUtil.parseOrThrow(uuid) }?.toByteArray()?.toByteString()
|
||||
val style = if (mention == null) {
|
||||
it.style?.toBackupBodyRangeStyle() ?: BackupBodyRange.Style.NONE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
BackupBodyRange(
|
||||
start = it.start,
|
||||
length = it.length,
|
||||
mentionAci = it.mentionUuid?.let { uuid -> UuidUtil.parseOrThrow(uuid) }?.toByteArray()?.toByteString(),
|
||||
style = it.style?.toBackupBodyRangeStyle()
|
||||
mentionAci = mention,
|
||||
style = style
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -899,7 +906,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
emoji = it.emoji,
|
||||
authorId = it.author.toLong(),
|
||||
sentTimestamp = it.dateSent,
|
||||
receivedTimestamp = it.dateReceived
|
||||
receivedTimestamp = it.dateReceived,
|
||||
sortOrder = 0 // TODO [backup] make this it.dateReceived once comparator support is added
|
||||
)
|
||||
} ?: emptyList()
|
||||
}
|
||||
@@ -913,48 +921,98 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
|
||||
return groupReceipts.toBackupSendStatus(this.networkFailureRecipientIds, this.identityMismatchRecipientIds)
|
||||
}
|
||||
|
||||
val status: SendStatus.Status = when {
|
||||
this.viewed -> SendStatus.Status.VIEWED
|
||||
this.hasReadReceipt -> SendStatus.Status.READ
|
||||
this.hasDeliveryReceipt -> SendStatus.Status.DELIVERED
|
||||
this.baseType == MessageTypes.BASE_SENT_TYPE -> SendStatus.Status.SENT
|
||||
MessageTypes.isFailedMessageType(this.type) -> SendStatus.Status.FAILED
|
||||
else -> SendStatus.Status.PENDING
|
||||
val statusBuilder = SendStatus.Builder()
|
||||
.recipientId(this.toRecipientId)
|
||||
.timestamp(this.receiptTimestamp)
|
||||
|
||||
when {
|
||||
this.identityMismatchRecipientIds.contains(this.toRecipientId) -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
identityKeyMismatch = true
|
||||
)
|
||||
}
|
||||
this.networkFailureRecipientIds.contains(this.toRecipientId) -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
network = true
|
||||
)
|
||||
}
|
||||
this.baseType == MessageTypes.BASE_SENT_TYPE -> {
|
||||
statusBuilder.sent = SendStatus.Sent(
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
this.hasDeliveryReceipt -> {
|
||||
statusBuilder.delivered = SendStatus.Delivered(
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
this.hasReadReceipt -> {
|
||||
statusBuilder.read = SendStatus.Read(
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
this.viewed -> {
|
||||
statusBuilder.viewed = SendStatus.Viewed(
|
||||
sealedSender = this.sealedSender
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
statusBuilder.pending = SendStatus.Pending()
|
||||
}
|
||||
}
|
||||
|
||||
return listOf(
|
||||
SendStatus(
|
||||
recipientId = this.toRecipientId,
|
||||
deliveryStatus = status,
|
||||
lastStatusUpdateTimestamp = this.receiptTimestamp,
|
||||
sealedSender = this.sealedSender,
|
||||
networkFailure = this.networkFailureRecipientIds.contains(this.toRecipientId),
|
||||
identityKeyMismatch = this.identityMismatchRecipientIds.contains(this.toRecipientId)
|
||||
)
|
||||
)
|
||||
return listOf(statusBuilder.build())
|
||||
}
|
||||
|
||||
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,
|
||||
lastStatusUpdateTimestamp = it.timestamp,
|
||||
networkFailure = networkFailureRecipientIds.contains(it.recipientId.toLong()),
|
||||
identityKeyMismatch = identityMismatchRecipientIds.contains(it.recipientId.toLong())
|
||||
)
|
||||
}
|
||||
}
|
||||
val statusBuilder = SendStatus.Builder()
|
||||
.recipientId(it.recipientId.toLong())
|
||||
.timestamp(it.timestamp)
|
||||
|
||||
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
|
||||
when {
|
||||
identityMismatchRecipientIds.contains(it.recipientId.toLong()) -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
identityKeyMismatch = true
|
||||
)
|
||||
}
|
||||
networkFailureRecipientIds.contains(it.recipientId.toLong()) -> {
|
||||
statusBuilder.failed = SendStatus.Failed(
|
||||
network = true
|
||||
)
|
||||
}
|
||||
it.status == GroupReceiptTable.STATUS_UNKNOWN -> {
|
||||
statusBuilder.pending = SendStatus.Pending()
|
||||
}
|
||||
it.status == GroupReceiptTable.STATUS_UNDELIVERED -> {
|
||||
statusBuilder.sent = SendStatus.Sent(
|
||||
sealedSender = it.isUnidentified
|
||||
)
|
||||
}
|
||||
it.status == GroupReceiptTable.STATUS_DELIVERED -> {
|
||||
statusBuilder.delivered = SendStatus.Delivered(
|
||||
sealedSender = it.isUnidentified
|
||||
)
|
||||
}
|
||||
it.status == GroupReceiptTable.STATUS_READ -> {
|
||||
statusBuilder.read = SendStatus.Read(
|
||||
sealedSender = it.isUnidentified
|
||||
)
|
||||
}
|
||||
it.status == GroupReceiptTable.STATUS_VIEWED -> {
|
||||
statusBuilder.viewed = SendStatus.Viewed(
|
||||
sealedSender = it.isUnidentified
|
||||
)
|
||||
}
|
||||
it.status == GroupReceiptTable.STATUS_SKIPPED -> {
|
||||
statusBuilder.skipped = SendStatus.Skipped()
|
||||
}
|
||||
else -> {
|
||||
statusBuilder.pending = SendStatus.Pending()
|
||||
}
|
||||
}
|
||||
|
||||
statusBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ import okio.ByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.forEach
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.toInt
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
import org.thoughtcrime.securesms.attachments.Cdn
|
||||
@@ -211,17 +213,13 @@ class ChatItemImportInserter(
|
||||
if (buffer.size == 0) {
|
||||
return false
|
||||
}
|
||||
buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages).forEach {
|
||||
db.rawQuery("${it.query.where} RETURNING ${MessageTable.ID}", it.query.whereArgs).use { cursor ->
|
||||
var index = 0
|
||||
while (cursor.moveToNext()) {
|
||||
val rowId = cursor.requireLong(MessageTable.ID)
|
||||
val followup = it.inserts[index].followUp
|
||||
if (followup != null) {
|
||||
followup(rowId)
|
||||
}
|
||||
index++
|
||||
}
|
||||
|
||||
var messageInsertIndex = 0
|
||||
SqlUtil.buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages.map { it.contentValues }).forEach { query ->
|
||||
db.rawQuery("${query.where} RETURNING ${MessageTable.ID}", query.whereArgs).forEach { cursor ->
|
||||
val finalMessageId = cursor.requireLong(MessageTable.ID)
|
||||
val relatedInsert = buffer.messages[messageInsertIndex++]
|
||||
relatedInsert.followUp?.invoke(finalMessageId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,15 +238,6 @@ class ChatItemImportInserter(
|
||||
return true
|
||||
}
|
||||
|
||||
private fun buildBulkInsert(tableName: String, columns: Array<String>, messageInserts: List<MessageInsert>, maxQueryArgs: Int = 999): List<BatchInsert> {
|
||||
val batchSize = maxQueryArgs / columns.size
|
||||
|
||||
return messageInserts
|
||||
.chunked(batchSize)
|
||||
.map { batch: List<MessageInsert> -> BatchInsert(batch, SqlUtil.buildSingleBulkInsert(tableName, columns, batch.map { it.contentValues })) }
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun ChatItem.toMessageInsert(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): MessageInsert {
|
||||
val contentValues = this.toMessageContentValues(fromRecipientId, chatRecipientId, threadId)
|
||||
|
||||
@@ -304,22 +293,22 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.paymentNotification != null) {
|
||||
followUp = { messageRowId ->
|
||||
val uuid = tryRestorePayment(this, chatRecipientId)
|
||||
if (uuid != null) {
|
||||
db.update(
|
||||
MessageTable.TABLE_NAME,
|
||||
contentValuesOf(
|
||||
db.update(MessageTable.TABLE_NAME)
|
||||
.values(
|
||||
MessageTable.BODY to uuid.toString(),
|
||||
MessageTable.TYPE to ((contentValues.getAsLong(MessageTable.TYPE) and MessageTypes.SPECIAL_TYPES_MASK.inv()) or MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION)
|
||||
),
|
||||
"${MessageTable.ID}=?",
|
||||
SqlUtil.buildArgs(messageRowId)
|
||||
)
|
||||
)
|
||||
.where("${MessageTable.ID} = ?", messageRowId)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.contactMessage != null) {
|
||||
val contacts = this.contactMessage.contact.map { backupContact ->
|
||||
Contact(
|
||||
@@ -352,9 +341,10 @@ class ChatItemImportInserter(
|
||||
address.country
|
||||
)
|
||||
},
|
||||
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true)
|
||||
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(), true)
|
||||
)
|
||||
}
|
||||
|
||||
val contactAttachments = contacts.mapNotNull { it.avatarAttachment }
|
||||
if (contacts.isNotEmpty()) {
|
||||
followUp = { messageRowId ->
|
||||
@@ -374,6 +364,7 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.standardMessage != null) {
|
||||
val bodyRanges = this.standardMessage.text?.bodyRanges
|
||||
if (!bodyRanges.isNullOrEmpty()) {
|
||||
@@ -399,9 +390,11 @@ class ChatItemImportInserter(
|
||||
val attachments = this.standardMessage.attachments.mapNotNull { attachment ->
|
||||
attachment.toLocalAttachment()
|
||||
}
|
||||
|
||||
val quoteAttachments = this.standardMessage.quote?.attachments?.mapNotNull {
|
||||
it.toLocalAttachment()
|
||||
} ?: emptyList()
|
||||
|
||||
if (attachments.isNotEmpty() || linkPreviewAttachments.isNotEmpty() || quoteAttachments.isNotEmpty()) {
|
||||
followUp = { messageRowId ->
|
||||
val attachmentMap = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments + linkPreviewAttachments, quoteAttachments)
|
||||
@@ -418,6 +411,7 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.stickerMessage != null) {
|
||||
val sticker = this.stickerMessage.sticker
|
||||
val attachment = sticker.toLocalAttachment()
|
||||
@@ -427,6 +421,7 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return MessageInsert(contentValues, followUp)
|
||||
}
|
||||
|
||||
@@ -442,7 +437,7 @@ class ChatItemImportInserter(
|
||||
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.incoming?.dateReceived ?: this.dateSent)
|
||||
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOfOrNull { it.lastStatusUpdateTimestamp } ?: 0)
|
||||
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOfOrNull { it.timestamp } ?: 0)
|
||||
contentValues.putNull(MessageTable.LATEST_REVISION_ID)
|
||||
contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID)
|
||||
contentValues.put(MessageTable.REVISION_NUMBER, 0)
|
||||
@@ -450,9 +445,9 @@ class ChatItemImportInserter(
|
||||
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStartDate ?: 0)
|
||||
|
||||
if (this.outgoing != null) {
|
||||
val viewed = this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.VIEWED }
|
||||
val hasReadReceipt = viewed || this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.READ }
|
||||
val hasDeliveryReceipt = viewed || hasReadReceipt || this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.DELIVERED }
|
||||
val viewed = this.outgoing.sendStatus.any { it.viewed != null }
|
||||
val hasReadReceipt = viewed || this.outgoing.sendStatus.any { it.read != null }
|
||||
val hasDeliveryReceipt = viewed || hasReadReceipt || this.outgoing.sendStatus.any { it.delivered != null }
|
||||
|
||||
contentValues.put(MessageTable.VIEWED_COLUMN, viewed.toInt())
|
||||
contentValues.put(MessageTable.HAS_READ_RECEIPT, hasReadReceipt.toInt())
|
||||
@@ -536,7 +531,7 @@ class ChatItemImportInserter(
|
||||
ReactionTable.MESSAGE_ID to messageId,
|
||||
ReactionTable.AUTHOR_ID to authorId,
|
||||
ReactionTable.DATE_SENT to it.sentTimestamp,
|
||||
ReactionTable.DATE_RECEIVED to it.receivedTimestamp,
|
||||
ReactionTable.DATE_RECEIVED to (it.receivedTimestamp ?: it.sortOrder),
|
||||
ReactionTable.EMOJI to it.emoji
|
||||
)
|
||||
} else {
|
||||
@@ -551,7 +546,7 @@ class ChatItemImportInserter(
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
// TODO This seems like an indirect/bad way to detect if this is a 1:1 or group convo
|
||||
// TODO [backup] 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()
|
||||
}
|
||||
@@ -563,8 +558,8 @@ class ChatItemImportInserter(
|
||||
contentValuesOf(
|
||||
GroupReceiptTable.MMS_ID to messageId,
|
||||
GroupReceiptTable.RECIPIENT_ID to recipientId.serialize(),
|
||||
GroupReceiptTable.STATUS to sendStatus.deliveryStatus.toLocalSendStatus(),
|
||||
GroupReceiptTable.TIMESTAMP to sendStatus.lastStatusUpdateTimestamp,
|
||||
GroupReceiptTable.STATUS to sendStatus.toLocalSendStatus(),
|
||||
GroupReceiptTable.TIMESTAMP to sendStatus.timestamp,
|
||||
GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender
|
||||
)
|
||||
} else {
|
||||
@@ -576,9 +571,9 @@ class ChatItemImportInserter(
|
||||
|
||||
private fun ChatItem.getMessageType(): Long {
|
||||
var type: Long = if (this.outgoing != null) {
|
||||
if (this.outgoing.sendStatus.count { it.identityKeyMismatch } > 0) {
|
||||
if (this.outgoing.sendStatus.count { it.failed?.identityKeyMismatch == true } > 0) {
|
||||
MessageTypes.BASE_SENT_FAILED_TYPE
|
||||
} else if (this.outgoing.sendStatus.count { it.networkFailure } > 0) {
|
||||
} else if (this.outgoing.sendStatus.count { it.failed?.network == true } > 0) {
|
||||
MessageTypes.BASE_SENDING_TYPE
|
||||
} else {
|
||||
MessageTypes.BASE_SENT_TYPE
|
||||
@@ -632,6 +627,7 @@ class ChatItemImportInserter(
|
||||
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST or typeWithoutBase
|
||||
SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE -> MessageTypes.UNSUPPORTED_MESSAGE_TYPE or typeWithoutBase
|
||||
SimpleChatUpdate.Type.REPORTED_SPAM -> MessageTypes.SPECIAL_TYPE_REPORTED_SPAM or typeWithoutBase
|
||||
else -> throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
updateMessage.expirationTimerChange != null -> {
|
||||
@@ -846,7 +842,7 @@ class ChatItemImportInserter(
|
||||
}
|
||||
|
||||
val networkFailures = chatItem.outgoing.sendStatus
|
||||
.filter { status -> status.networkFailure }
|
||||
.filter { status -> status.failed?.network ?: false }
|
||||
.mapNotNull { status -> importState.remoteToLocalRecipientId[status.recipientId] }
|
||||
.map { recipientId -> NetworkFailure(recipientId) }
|
||||
.toSet()
|
||||
@@ -862,7 +858,7 @@ class ChatItemImportInserter(
|
||||
}
|
||||
|
||||
val mismatches = chatItem.outgoing.sendStatus
|
||||
.filter { status -> status.identityKeyMismatch }
|
||||
.filter { status -> status.failed?.identityKeyMismatch ?: false }
|
||||
.mapNotNull { status -> importState.remoteToLocalRecipientId[status.recipientId] }
|
||||
.map { recipientId -> IdentityKeyMismatch(recipientId, null) } // TODO We probably want the actual identity key in this status situation?
|
||||
.toSet()
|
||||
@@ -898,101 +894,73 @@ class ChatItemImportInserter(
|
||||
)
|
||||
}
|
||||
|
||||
private fun SendStatus.Status.toLocalSendStatus(): Int {
|
||||
return when (this) {
|
||||
SendStatus.Status.UNKNOWN -> GroupReceiptTable.STATUS_UNKNOWN
|
||||
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 fun SendStatus.toLocalSendStatus(): Int {
|
||||
return when {
|
||||
this.pending != null -> GroupReceiptTable.STATUS_UNKNOWN
|
||||
this.sent != null -> GroupReceiptTable.STATUS_UNDELIVERED
|
||||
this.delivered != null -> GroupReceiptTable.STATUS_DELIVERED
|
||||
this.read != null -> GroupReceiptTable.STATUS_READ
|
||||
this.viewed != null -> GroupReceiptTable.STATUS_VIEWED
|
||||
this.skipped != null -> GroupReceiptTable.STATUS_SKIPPED
|
||||
this.failed != null -> GroupReceiptTable.STATUS_UNKNOWN
|
||||
else -> GroupReceiptTable.STATUS_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
private fun FilePointer?.toLocalAttachment(voiceNote: Boolean, borderless: Boolean, gif: Boolean, wasDownloaded: Boolean, stickerLocator: StickerLocator? = null, contentType: String? = this?.contentType, fileName: String? = this?.fileName, uuid: ByteString? = null): Attachment? {
|
||||
if (this == null) return null
|
||||
|
||||
if (attachmentLocator != null) {
|
||||
val signalAttachmentPointer = SignalServiceAttachmentPointer(
|
||||
attachmentLocator.cdnNumber,
|
||||
SignalServiceAttachmentRemoteId.from(attachmentLocator.cdnKey),
|
||||
contentType,
|
||||
attachmentLocator.key.toByteArray(),
|
||||
Optional.ofNullable(attachmentLocator.size),
|
||||
Optional.empty(),
|
||||
width ?: 0,
|
||||
height ?: 0,
|
||||
Optional.ofNullable(attachmentLocator.digest.toByteArray()),
|
||||
Optional.ofNullable(incrementalMac?.toByteArray()),
|
||||
incrementalMacChunkSize ?: 0,
|
||||
Optional.ofNullable(fileName),
|
||||
voiceNote,
|
||||
borderless,
|
||||
gif,
|
||||
Optional.ofNullable(caption),
|
||||
Optional.ofNullable(blurHash),
|
||||
attachmentLocator.uploadTimestamp,
|
||||
UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
return PointerAttachment.forPointer(
|
||||
pointer = Optional.of(signalAttachmentPointer),
|
||||
stickerLocator = stickerLocator,
|
||||
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
|
||||
).orNull()
|
||||
} else if (invalidAttachmentLocator != null) {
|
||||
return TombstoneAttachment(
|
||||
contentType = contentType,
|
||||
incrementalMac = incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = incrementalMacChunkSize,
|
||||
width = width,
|
||||
height = height,
|
||||
caption = caption,
|
||||
blurHash = blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
} else if (backupLocator != null) {
|
||||
return ArchivedAttachment(
|
||||
contentType = contentType,
|
||||
size = backupLocator.size.toLong(),
|
||||
cdn = backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
key = backupLocator.key.toByteArray(),
|
||||
cdnKey = backupLocator.transitCdnKey,
|
||||
archiveCdn = backupLocator.cdnNumber,
|
||||
archiveMediaName = backupLocator.mediaName,
|
||||
archiveMediaId = importState.backupKey.deriveMediaId(MediaName(backupLocator.mediaName)).encode(),
|
||||
archiveThumbnailMediaId = importState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(backupLocator.mediaName)).encode(),
|
||||
digest = backupLocator.digest.toByteArray(),
|
||||
incrementalMac = incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = incrementalMacChunkSize,
|
||||
width = width,
|
||||
height = height,
|
||||
caption = caption,
|
||||
blurHash = blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
stickerLocator = stickerLocator,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
private val SendStatus.sealedSender: Boolean
|
||||
get() {
|
||||
return this.sent?.sealedSender
|
||||
?: this.delivered?.sealedSender
|
||||
?: this.read?.sealedSender
|
||||
?: this.viewed?.sealedSender
|
||||
?: false
|
||||
}
|
||||
|
||||
private fun LinkPreview.toLocalLinkPreview(): org.thoughtcrime.securesms.linkpreview.LinkPreview {
|
||||
return org.thoughtcrime.securesms.linkpreview.LinkPreview(
|
||||
this.url,
|
||||
this.title ?: "",
|
||||
this.description ?: "",
|
||||
this.date ?: 0,
|
||||
Optional.ofNullable(this.image?.toLocalAttachment())
|
||||
)
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(contentType: String? = this.pointer?.contentType, fileName: String? = this.pointer?.fileName): Attachment? {
|
||||
return this.pointer?.toLocalAttachment(
|
||||
voiceNote = this.flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
borderless = this.flag == MessageAttachment.Flag.BORDERLESS,
|
||||
gif = this.flag == MessageAttachment.Flag.GIF,
|
||||
wasDownloaded = this.wasDownloaded,
|
||||
stickerLocator = null,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
uuid = this.clientUuid
|
||||
)
|
||||
}
|
||||
|
||||
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
|
||||
val thumbnail = this.thumbnail?.toLocalAttachment(this.contentType, this.fileName)
|
||||
|
||||
return if (thumbnail != null) {
|
||||
thumbnail
|
||||
} else if (this.contentType == null) {
|
||||
null
|
||||
} else {
|
||||
PointerAttachment.forPointer(
|
||||
quotedAttachment = DataMessage.Quote.QuotedAttachment(
|
||||
contentType = this.contentType,
|
||||
fileName = this.fileName,
|
||||
thumbnail = null
|
||||
)
|
||||
).orNull()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Sticker?.toLocalAttachment(): Attachment? {
|
||||
if (this == null) return null
|
||||
|
||||
return data_.toLocalAttachment(
|
||||
voiceNote = false,
|
||||
gif = false,
|
||||
borderless = false,
|
||||
wasDownloaded = true,
|
||||
stickerLocator = StickerLocator(
|
||||
packId = Hex.toStringCondensed(packId.toByteArray()),
|
||||
packKey = Hex.toStringCondensed(packKey.toByteArray()),
|
||||
@@ -1002,24 +970,89 @@ class ChatItemImportInserter(
|
||||
)
|
||||
}
|
||||
|
||||
private fun LinkPreview.toLocalLinkPreview(): org.thoughtcrime.securesms.linkpreview.LinkPreview {
|
||||
return org.thoughtcrime.securesms.linkpreview.LinkPreview(
|
||||
this.url,
|
||||
this.title ?: "",
|
||||
this.description ?: "",
|
||||
this.date ?: 0,
|
||||
Optional.ofNullable(this.image?.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true))
|
||||
)
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(): Attachment? {
|
||||
return pointer?.toLocalAttachment(
|
||||
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
gif = flag == MessageAttachment.Flag.GIF,
|
||||
borderless = flag == MessageAttachment.Flag.BORDERLESS,
|
||||
wasDownloaded = wasDownloaded,
|
||||
uuid = clientUuid
|
||||
)
|
||||
private fun FilePointer?.toLocalAttachment(
|
||||
borderless: Boolean = false,
|
||||
gif: Boolean = false,
|
||||
voiceNote: Boolean = false,
|
||||
wasDownloaded: Boolean = true,
|
||||
stickerLocator: StickerLocator? = null,
|
||||
contentType: String? = this?.contentType,
|
||||
fileName: String? = this?.fileName,
|
||||
uuid: ByteString? = null
|
||||
): Attachment? {
|
||||
return if (this == null) {
|
||||
null
|
||||
} else if (this.attachmentLocator != null) {
|
||||
val signalAttachmentPointer = SignalServiceAttachmentPointer(
|
||||
cdnNumber = this.attachmentLocator.cdnNumber,
|
||||
remoteId = SignalServiceAttachmentRemoteId.from(this.attachmentLocator.cdnKey),
|
||||
contentType = contentType,
|
||||
key = this.attachmentLocator.key.toByteArray(),
|
||||
size = Optional.ofNullable(this.attachmentLocator.size),
|
||||
preview = Optional.empty(),
|
||||
width = this.width ?: 0,
|
||||
height = this.height ?: 0,
|
||||
digest = Optional.ofNullable(this.attachmentLocator.digest.toByteArray()),
|
||||
incrementalDigest = Optional.ofNullable(this.incrementalMac?.toByteArray()),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize ?: 0,
|
||||
fileName = Optional.ofNullable(fileName),
|
||||
voiceNote = voiceNote,
|
||||
isBorderless = borderless,
|
||||
isGif = gif,
|
||||
caption = Optional.ofNullable(this.caption),
|
||||
blurHash = Optional.ofNullable(this.blurHash),
|
||||
uploadTimestamp = this.attachmentLocator.uploadTimestamp,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
PointerAttachment.forPointer(
|
||||
pointer = Optional.of(signalAttachmentPointer),
|
||||
stickerLocator = stickerLocator,
|
||||
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
|
||||
).orNull()
|
||||
} else if (this.invalidAttachmentLocator != null) {
|
||||
TombstoneAttachment(
|
||||
contentType = contentType,
|
||||
incrementalMac = this.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize,
|
||||
width = this.width,
|
||||
height = this.height,
|
||||
caption = this.caption,
|
||||
blurHash = this.blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid)
|
||||
)
|
||||
} else if (this.backupLocator != null) {
|
||||
ArchivedAttachment(
|
||||
contentType = contentType,
|
||||
size = this.backupLocator.size.toLong(),
|
||||
cdn = this.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
|
||||
key = this.backupLocator.key.toByteArray(),
|
||||
cdnKey = this.backupLocator.transitCdnKey,
|
||||
archiveCdn = this.backupLocator.cdnNumber,
|
||||
archiveMediaName = this.backupLocator.mediaName,
|
||||
archiveMediaId = importState.backupKey.deriveMediaId(MediaName(this.backupLocator.mediaName)).encode(),
|
||||
archiveThumbnailMediaId = importState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(this.backupLocator.mediaName)).encode(),
|
||||
digest = this.backupLocator.digest.toByteArray(),
|
||||
incrementalMac = this.incrementalMac?.toByteArray(),
|
||||
incrementalMacChunkSize = this.incrementalMacChunkSize,
|
||||
width = this.width,
|
||||
height = this.height,
|
||||
caption = this.caption,
|
||||
blurHash = this.blurHash,
|
||||
voiceNote = voiceNote,
|
||||
borderless = borderless,
|
||||
gif = gif,
|
||||
quote = false,
|
||||
stickerLocator = stickerLocator,
|
||||
uuid = UuidUtil.fromByteStringOrNull(uuid),
|
||||
fileName = fileName
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContactAttachment.Name?.toLocal(): Contact.Name {
|
||||
@@ -1058,23 +1091,6 @@ class ChatItemImportInserter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun MessageAttachment.toLocalAttachment(contentType: String?, fileName: String?): Attachment? {
|
||||
return pointer?.toLocalAttachment(
|
||||
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
|
||||
gif = flag == MessageAttachment.Flag.GIF,
|
||||
borderless = flag == MessageAttachment.Flag.BORDERLESS,
|
||||
wasDownloaded = wasDownloaded,
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
uuid = clientUuid
|
||||
)
|
||||
}
|
||||
|
||||
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
|
||||
return thumbnail?.toLocalAttachment(this.contentType, this.fileName)
|
||||
?: if (this.contentType == null) null else PointerAttachment.forPointer(quotedAttachment = DataMessage.Quote.QuotedAttachment(contentType = this.contentType, fileName = this.fileName, thumbnail = null)).orNull()
|
||||
}
|
||||
|
||||
private class MessageInsert(
|
||||
val contentValues: ContentValues,
|
||||
val followUp: ((Long) -> Unit)?,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.database.InAppPaymentTable
|
||||
|
||||
fun InAppPaymentTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit
|
||||
private val TAG = Log.tag(MessageTable::class.java)
|
||||
private const val BASE_TYPE = "base_type"
|
||||
|
||||
fun MessageTable.getMessagesForBackup(backupTime: Long, archiveMedia: Boolean): ChatItemExportIterator {
|
||||
fun MessageTable.getMessagesForBackup(backupTime: Long, mediaBackupEnabled: Boolean): ChatItemExportIterator {
|
||||
val cursor = readableDatabase
|
||||
.select(
|
||||
MessageTable.ID,
|
||||
@@ -66,7 +66,7 @@ fun MessageTable.getMessagesForBackup(backupTime: Long, archiveMedia: Boolean):
|
||||
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
|
||||
.run()
|
||||
|
||||
return ChatItemExportIterator(cursor, 100, archiveMedia)
|
||||
return ChatItemExportIterator(cursor, 100, mediaBackupEnabled)
|
||||
}
|
||||
|
||||
fun MessageTable.createChatItemInserter(importState: ImportState): ChatItemImportInserter {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.backup.v2.database
|
||||
|
||||
import org.signal.core.util.deleteAll
|
||||
import org.thoughtcrime.securesms.database.ReactionTable
|
||||
|
||||
fun ReactionTable.clearAllDataForBackupRestore() {
|
||||
writableDatabase.deleteAll(ReactionTable.TABLE_NAME)
|
||||
}
|
||||
@@ -303,22 +303,23 @@ private fun Member.Role.toSnapshot(): Group.Member.Role {
|
||||
}
|
||||
|
||||
private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? {
|
||||
if (revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION || revision == GroupsV2StateProcessor.PLACEHOLDER_REVISION) {
|
||||
if (this.revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION || this.revision == GroupsV2StateProcessor.PLACEHOLDER_REVISION) {
|
||||
return null
|
||||
}
|
||||
|
||||
return Group.GroupSnapshot(
|
||||
title = Group.GroupAttributeBlob(title = title),
|
||||
avatarUrl = avatar,
|
||||
disappearingMessagesTimer = Group.GroupAttributeBlob(disappearingMessagesDuration = disappearingMessagesTimer?.duration ?: 0),
|
||||
accessControl = accessControl?.toSnapshot(),
|
||||
version = revision,
|
||||
members = members.map { it.toSnapshot() },
|
||||
membersPendingProfileKey = pendingMembers.map { it.toSnapshot() },
|
||||
membersPendingAdminApproval = requestingMembers.map { it.toSnapshot() },
|
||||
inviteLinkPassword = inviteLinkPassword,
|
||||
description = Group.GroupAttributeBlob(descriptionText = description),
|
||||
announcements_only = isAnnouncementGroup == EnabledState.ENABLED,
|
||||
members_banned = bannedMembers.map { it.toSnapshot() }
|
||||
title = Group.GroupAttributeBlob(title = this.title),
|
||||
avatarUrl = this.avatar,
|
||||
disappearingMessagesTimer = Group.GroupAttributeBlob(disappearingMessagesDuration = this.disappearingMessagesTimer?.duration ?: 0),
|
||||
accessControl = this.accessControl?.toSnapshot(),
|
||||
version = this.revision,
|
||||
members = this.members.map { it.toSnapshot() },
|
||||
membersPendingProfileKey = this.pendingMembers.map { it.toSnapshot() },
|
||||
membersPendingAdminApproval = this.requestingMembers.map { it.toSnapshot() },
|
||||
inviteLinkPassword = this.inviteLinkPassword,
|
||||
description = this.description.takeUnless { it.isBlank() }?.let { Group.GroupAttributeBlob(descriptionText = it) },
|
||||
announcements_only = this.isAnnouncementGroup == EnabledState.ENABLED,
|
||||
members_banned = this.bannedMembers.map { it.toSnapshot() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -343,58 +344,58 @@ private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations
|
||||
private fun DecryptedPendingMember.toSnapshot(): Group.MemberPendingProfileKey {
|
||||
return Group.MemberPendingProfileKey(
|
||||
member = Group.Member(
|
||||
userId = serviceIdBytes,
|
||||
role = role.toSnapshot()
|
||||
userId = this.serviceIdBytes,
|
||||
role = this.role.toSnapshot()
|
||||
),
|
||||
addedByUserId = addedByAci,
|
||||
timestamp = timestamp
|
||||
addedByUserId = this.addedByAci,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMember {
|
||||
return DecryptedRequestingMember(
|
||||
aciBytes = userId,
|
||||
profileKey = profileKey,
|
||||
timestamp = timestamp
|
||||
aciBytes = this.userId,
|
||||
profileKey = this.profileKey,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun DecryptedRequestingMember.toSnapshot(): Group.MemberPendingAdminApproval {
|
||||
return Group.MemberPendingAdminApproval(
|
||||
userId = aciBytes,
|
||||
profileKey = profileKey,
|
||||
timestamp = timestamp
|
||||
userId = this.aciBytes,
|
||||
profileKey = this.profileKey,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.MemberBanned.toLocal(): DecryptedBannedMember {
|
||||
return DecryptedBannedMember(
|
||||
serviceIdBytes = userId,
|
||||
timestamp = timestamp
|
||||
serviceIdBytes = this.userId,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun DecryptedBannedMember.toSnapshot(): Group.MemberBanned {
|
||||
return Group.MemberBanned(
|
||||
userId = serviceIdBytes,
|
||||
timestamp = timestamp
|
||||
userId = this.serviceIdBytes,
|
||||
timestamp = this.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
private fun Group.GroupSnapshot.toDecryptedGroup(operations: GroupsV2Operations.GroupOperations): DecryptedGroup {
|
||||
return DecryptedGroup(
|
||||
title = title?.title ?: "",
|
||||
avatar = avatarUrl,
|
||||
disappearingMessagesTimer = DecryptedTimer(duration = disappearingMessagesTimer?.disappearingMessagesDuration ?: 0),
|
||||
accessControl = accessControl?.toLocal(),
|
||||
revision = version,
|
||||
members = members.map { member -> member.toLocal() },
|
||||
pendingMembers = membersPendingProfileKey.map { pending -> pending.toLocal(operations) },
|
||||
requestingMembers = membersPendingAdminApproval.map { requesting -> requesting.toLocal() },
|
||||
inviteLinkPassword = inviteLinkPassword,
|
||||
description = description?.descriptionText ?: "",
|
||||
isAnnouncementGroup = if (announcements_only) EnabledState.ENABLED else EnabledState.DISABLED,
|
||||
bannedMembers = members_banned.map { it.toLocal() }
|
||||
title = this.title?.title ?: "",
|
||||
avatar = this.avatarUrl,
|
||||
disappearingMessagesTimer = DecryptedTimer(duration = this.disappearingMessagesTimer?.disappearingMessagesDuration ?: 0),
|
||||
accessControl = this.accessControl?.toLocal(),
|
||||
revision = this.version,
|
||||
members = this.members.map { member -> member.toLocal() },
|
||||
pendingMembers = this.membersPendingProfileKey.map { pending -> pending.toLocal(operations) },
|
||||
requestingMembers = this.membersPendingAdminApproval.map { requesting -> requesting.toLocal() },
|
||||
inviteLinkPassword = this.inviteLinkPassword,
|
||||
description = this.description?.descriptionText ?: "",
|
||||
isAnnouncementGroup = if (this.announcements_only) EnabledState.ENABLED else EnabledState.DISABLED,
|
||||
bannedMembers = this.members_banned.map { it.toLocal() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ object ChatItemBackupProcessor {
|
||||
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
|
||||
|
||||
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
|
||||
db.messageTable.getMessagesForBackup(exportState.backupTime, exportState.allowMediaBackup).use { chatItems ->
|
||||
db.messageTable.getMessagesForBackup(exportState.backupTime, exportState.mediaBackupEnabled).use { chatItems ->
|
||||
while (chatItems.hasNext()) {
|
||||
val chatItem = chatItems.next()
|
||||
if (chatItem != null) {
|
||||
|
||||
@@ -1855,6 +1855,7 @@ class AttachmentTable(
|
||||
put(ARCHIVE_THUMBNAIL_MEDIA_ID, attachment.archiveThumbnailMediaId)
|
||||
put(THUMBNAIL_RESTORE_STATE, ThumbnailRestoreState.NEEDS_RESTORE.value)
|
||||
put(ATTACHMENT_UUID, attachment.uuid?.toString())
|
||||
put(BLUR_HASH, attachment.blurHash?.hash)
|
||||
|
||||
attachment.stickerLocator?.let { sticker ->
|
||||
put(STICKER_PACK_ID, sticker.packId)
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
class ChatColorsTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private const val TABLE_NAME = "chat_colors"
|
||||
const val TABLE_NAME = "chat_colors"
|
||||
private const val ID = "_id"
|
||||
private const val CHAT_COLORS = "chat_colors"
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ import kotlin.time.Duration.Companion.seconds
|
||||
class InAppPaymentTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
|
||||
|
||||
companion object {
|
||||
private const val TABLE_NAME = "in_app_payment"
|
||||
const val TABLE_NAME = "in_app_payment"
|
||||
|
||||
/**
|
||||
* Row ID
|
||||
|
||||
@@ -234,6 +234,10 @@ public class KeyValueDatabase extends SQLiteOpenHelper implements SignalDatabase
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
getWritableDatabase().delete(TABLE_NAME, null, null);
|
||||
}
|
||||
|
||||
private enum Type {
|
||||
BLOB(0), BOOLEAN(1), FLOAT(2), INTEGER(3), LONG(4), STRING(5);
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ object GroupsV2UpdateMessageConverter {
|
||||
fun translateDecryptedChangeUpdate(selfIds: ServiceIds, groupContext: DecryptedGroupV2Context): GroupChangeChatUpdate {
|
||||
var previousGroupState = groupContext.previousGroupState
|
||||
val change = groupContext.change!!
|
||||
if (DecryptedGroup().equals(previousGroupState)) {
|
||||
if (DecryptedGroup() == previousGroupState) {
|
||||
previousGroupState = null
|
||||
}
|
||||
val updates: MutableList<GroupChangeChatUpdate.Update> = LinkedList()
|
||||
|
||||
@@ -433,7 +433,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
BuildConfig.SIGNAL_AGENT,
|
||||
healthMonitor,
|
||||
Stories.isFeatureEnabled(),
|
||||
LibSignalNetworkExtensions.createChatService(libSignalNetworkSupplier.get(), null),
|
||||
LibSignalNetworkExtensions.createChatService(libSignalNetworkSupplier.get(), null, Stories.isFeatureEnabled()),
|
||||
shadowPercentage,
|
||||
bridge
|
||||
);
|
||||
@@ -442,7 +442,7 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
|
||||
Network network = libSignalNetworkSupplier.get();
|
||||
return new LibSignalChatConnection(
|
||||
"libsignal-unauth",
|
||||
LibSignalNetworkExtensions.createChatService(network, null),
|
||||
LibSignalNetworkExtensions.createChatService(network, null, Stories.isFeatureEnabled()),
|
||||
healthMonitor,
|
||||
false);
|
||||
} else {
|
||||
|
||||
@@ -146,10 +146,10 @@ class ArchiveThumbnailUploadJob private constructor(
|
||||
val uri: DecryptableUri = attachment.uri?.let { DecryptableUri(it) } ?: return null
|
||||
|
||||
return if (MediaUtil.isImageType(attachment.contentType)) {
|
||||
ImageCompressionUtil.compress(context, attachment.contentType, uri, 256, 50)
|
||||
ImageCompressionUtil.compress(context, attachment.contentType ?: "", uri, 256, 50)
|
||||
} else if (Build.VERSION.SDK_INT >= 23 && MediaUtil.isVideoType(attachment.contentType)) {
|
||||
MediaUtil.getVideoThumbnail(context, attachment.uri)?.let {
|
||||
ImageCompressionUtil.compress(context, attachment.contentType, uri, 256, 50)
|
||||
ImageCompressionUtil.compress(context, attachment.contentType ?: "", uri, 256, 50)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
|
||||
@@ -288,10 +288,5 @@ class SignalStore(private val store: KeyValueStore) {
|
||||
instanceOverride = store
|
||||
_instance.reset()
|
||||
}
|
||||
|
||||
fun clearAllDataForBackupRestore() {
|
||||
releaseChannel.clearReleaseChannelRecipientId()
|
||||
account.clearRegistrationButKeepCredentials()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ message Group {
|
||||
// We would use Groups.proto if we could, but we want a plaintext version to improve export readability.
|
||||
// For documentation, defer to Groups.proto. The only name change is Group -> GroupSnapshot to avoid the naming conflict.
|
||||
message GroupSnapshot {
|
||||
bytes publicKey = 1;
|
||||
reserved /*publicKey*/ 1; // The field is deprecated in the context of static group state
|
||||
GroupAttributeBlob title = 2;
|
||||
GroupAttributeBlob description = 11;
|
||||
string avatarUrl = 3;
|
||||
@@ -343,23 +343,46 @@ message ChatItem {
|
||||
}
|
||||
|
||||
message SendStatus {
|
||||
enum Status {
|
||||
UNKNOWN = 0;
|
||||
FAILED = 1;
|
||||
PENDING = 2;
|
||||
SENT = 3;
|
||||
DELIVERED = 4;
|
||||
READ = 5;
|
||||
VIEWED = 6;
|
||||
SKIPPED = 7; // e.g. user in group was blocked, so we skipped sending to them
|
||||
message Pending {}
|
||||
|
||||
message Sent {
|
||||
bool sealedSender = 1;
|
||||
}
|
||||
|
||||
message Delivered {
|
||||
bool sealedSender = 1;
|
||||
}
|
||||
|
||||
message Read {
|
||||
bool sealedSender = 1;
|
||||
}
|
||||
|
||||
message Viewed {
|
||||
bool sealedSender = 1;
|
||||
}
|
||||
|
||||
// e.g. user in group was blocked, so we skipped sending to them
|
||||
message Skipped {}
|
||||
|
||||
message Failed {
|
||||
oneof reason {
|
||||
bool network = 1;
|
||||
bool identityKeyMismatch = 2;
|
||||
}
|
||||
}
|
||||
|
||||
uint64 recipientId = 1;
|
||||
Status deliveryStatus = 2;
|
||||
bool networkFailure = 3;
|
||||
bool identityKeyMismatch = 4;
|
||||
bool sealedSender = 5;
|
||||
uint64 lastStatusUpdateTimestamp = 6; // the time the status was last updated -- if from a receipt, it should be the sentTime of the receipt
|
||||
uint64 timestamp = 2; // the time the status was last updated -- if from a receipt, it should be the sentTime of the receipt
|
||||
|
||||
oneof deliveryStatus {
|
||||
Pending pending = 3;
|
||||
Sent sent = 4;
|
||||
Delivered delivered = 5;
|
||||
Read read = 6;
|
||||
Viewed viewed = 7;
|
||||
Skipped skipped = 8;
|
||||
Failed failed = 9;
|
||||
}
|
||||
}
|
||||
|
||||
message Text {
|
||||
@@ -565,7 +588,7 @@ message FilePointer {
|
||||
optional uint32 cdnNumber = 2;
|
||||
bytes key = 3;
|
||||
bytes digest = 4;
|
||||
uint64 size = 5;
|
||||
uint32 size = 5;
|
||||
// Fallback in case backup tier upload failed.
|
||||
optional string transitCdnKey = 6;
|
||||
optional uint32 transitCdnNumber = 7;
|
||||
@@ -652,8 +675,11 @@ message Reaction {
|
||||
string emoji = 1;
|
||||
uint64 authorId = 2;
|
||||
uint64 sentTimestamp = 3;
|
||||
// Optional because some clients may not track this data
|
||||
optional uint64 receivedTimestamp = 4;
|
||||
uint64 sortOrder = 5; // A higher sort order means that a reaction is more recent
|
||||
// A higher sort order means that a reaction is more recent. Some clients may export this as
|
||||
// incrementing numbers (e.g. 1, 2, 3), others as timestamps.
|
||||
uint64 sortOrder = 5;
|
||||
}
|
||||
|
||||
message ChatUpdateMessage {
|
||||
@@ -749,6 +775,9 @@ message SimpleChatUpdate {
|
||||
PAYMENT_ACTIVATION_REQUEST = 11;
|
||||
UNSUPPORTED_PROTOCOL_MESSAGE = 12;
|
||||
REPORTED_SPAM = 13;
|
||||
BLOCKED = 14;
|
||||
UNBLOCKED = 15;
|
||||
ACCEPTED = 16;
|
||||
}
|
||||
|
||||
Type type = 1;
|
||||
|
||||
@@ -202,6 +202,15 @@ inline fun Cursor.forEach(operation: (Cursor) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
inline fun Cursor.forEachIndexed(operation: (Int, Cursor) -> Unit) {
|
||||
use {
|
||||
var i = 0
|
||||
while (moveToNext()) {
|
||||
operation(i++, this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Cursor.iterable(): Iterable<Cursor> {
|
||||
return CursorIterable(this)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ dependencyResolutionManagement {
|
||||
version("exoplayer", "2.19.0")
|
||||
version("glide", "4.15.1")
|
||||
version("kotlin", "1.9.20")
|
||||
version("libsignal-client", "0.52.5")
|
||||
version("libsignal-client", "0.54.3")
|
||||
version("mp4parser", "1.9.39")
|
||||
version("android-gradle-plugin", "8.4.0")
|
||||
version("accompanist", "0.28.0")
|
||||
|
||||
@@ -8760,20 +8760,20 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="6eb4422e8a618b3b76cb2096a3619d251f9e27989dc68307a1e5414c3710f2d1" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.signal" name="libsignal-android" version="0.52.5">
|
||||
<artifact name="libsignal-android-0.52.5.aar">
|
||||
<sha256 value="a524c5dbe8f6cbd3938308b712d7c7b456ac94a243ede692c55dc9a8736f5290" origin="Generated by Gradle"/>
|
||||
<component group="org.signal" name="libsignal-android" version="0.54.3">
|
||||
<artifact name="libsignal-android-0.54.3.aar">
|
||||
<sha256 value="6aaeec75f1b7d8e0b924ccb1e8fa9224a699b17fb8dd552f3304d31e96ab8167" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="libsignal-android-0.52.5.module">
|
||||
<sha256 value="810d8ef4cfdbd248fca2a4549b194a01286b862305b275bed13dd807d1d57854" origin="Generated by Gradle"/>
|
||||
<artifact name="libsignal-android-0.54.3.module">
|
||||
<sha256 value="2a4aafa8afa0f07252b404915c41cc13022b9d23294dcbea3ab56819afa00515" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.signal" name="libsignal-client" version="0.52.5">
|
||||
<artifact name="libsignal-client-0.52.5.jar">
|
||||
<sha256 value="ec9f222d50f4acfd2294e598f442d954c70e42a23aacb4ba075d317b8a53ef9d" origin="Generated by Gradle"/>
|
||||
<component group="org.signal" name="libsignal-client" version="0.54.3">
|
||||
<artifact name="libsignal-client-0.54.3.jar">
|
||||
<sha256 value="ac652d8b5a1c49e0ee9e4b1aa5a1a6bd97fe713c9cbb7e5a8fbe1f1c2227da68" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="libsignal-client-0.52.5.module">
|
||||
<sha256 value="549a433fbc50a3ff88295c8d29344e24edd69e07606f69b7e7299802be732792" origin="Generated by Gradle"/>
|
||||
<artifact name="libsignal-client-0.54.3.module">
|
||||
<sha256 value="22d410b8a5e9b72c8c6f5cbbc37e06aa7335e14c0e78b98b63e6fbd002ae1692" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.signal" name="ringrtc-android" version="2.46.0">
|
||||
|
||||
@@ -16,11 +16,12 @@ import org.whispersystems.signalservice.internal.configuration.SignalServiceConf
|
||||
* Helper method to create a ChatService with optional credentials.
|
||||
*/
|
||||
fun Network.createChatService(
|
||||
credentialsProvider: CredentialsProvider? = null
|
||||
credentialsProvider: CredentialsProvider? = null,
|
||||
receiveStories: Boolean
|
||||
): ChatService {
|
||||
val username = credentialsProvider?.username ?: ""
|
||||
val password = credentialsProvider?.password ?: ""
|
||||
return this.createChatService(username, password)
|
||||
return this.createChatService(username, password, receiveStories)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user