Update to latest Backup.proto and fix various backup bugs.

This commit is contained in:
Greyson Parrelli
2024-08-09 16:04:47 -04:00
committed by mtang-signal
parent e2e6a73e8d
commit 5ffb7b07da
31 changed files with 761 additions and 2182 deletions

View File

@@ -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)
}
}

View File

@@ -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")
}
}
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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")
}
}
}

View File

@@ -83,7 +83,7 @@ class DatabaseAttachment : Attachment {
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
uuid: UUID?
) : super(
contentType = contentType!!,
contentType = contentType,
transferState = transferProgress,
size = size,
fileName = fileName,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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)?,

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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() }
)
}

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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"

View File

@@ -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

View File

@@ -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);

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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

View File

@@ -288,10 +288,5 @@ class SignalStore(private val store: KeyValueStore) {
instanceOverride = store
_instance.reset()
}
fun clearAllDataForBackupRestore() {
releaseChannel.clearReleaseChannelRecipientId()
account.clearRegistrationButKeepCredentials()
}
}
}

View File

@@ -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;

View File

@@ -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)
}

View File

@@ -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")

View File

@@ -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">

View File

@@ -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)
}
/**