Export backupV2 using actual desired file format.

This commit is contained in:
Greyson Parrelli
2023-11-21 11:22:15 -05:00
committed by Cody Henthorne
parent fb69fc5af2
commit befa396e82
42 changed files with 1565 additions and 424 deletions

View File

@@ -8,52 +8,68 @@ package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.EventTimer
import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportStream
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupExportStream
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupImportStream
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import java.io.ByteArrayInputStream
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.io.ByteArrayOutputStream
import java.io.InputStream
object BackupRepository {
private val TAG = Log.tag(BackupRepository::class.java)
fun export(): ByteArray {
fun export(plaintext: Boolean = false): ByteArray {
val eventTimer = EventTimer()
val outputStream = ByteArrayOutputStream()
val writer: BackupExportStream = PlainTextBackupExportStream(outputStream)
val writer: BackupExportWriter = if (plaintext) {
PlainTextBackupWriter(outputStream)
} else {
EncryptedBackupWriter(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account().aci!!,
outputStream = outputStream,
append = { mac -> outputStream.write(mac) }
)
}
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
// writes from other threads are blocked. This is something to think more about.
SignalDatabase.rawDatabase.withinTransaction {
AccountDataProcessor.export {
writer.write(it)
eventTimer.emit("account")
}
writer.use {
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
// writes from other threads are blocked. This is something to think more about.
SignalDatabase.rawDatabase.withinTransaction {
AccountDataProcessor.export {
writer.write(it)
eventTimer.emit("account")
}
RecipientBackupProcessor.export {
writer.write(it)
eventTimer.emit("recipient")
}
RecipientBackupProcessor.export {
writer.write(it)
eventTimer.emit("recipient")
}
ChatBackupProcessor.export { frame ->
writer.write(frame)
eventTimer.emit("thread")
}
ChatBackupProcessor.export { frame ->
writer.write(frame)
eventTimer.emit("thread")
}
ChatItemBackupProcessor.export { frame ->
writer.write(frame)
eventTimer.emit("message")
ChatItemBackupProcessor.export { frame ->
writer.write(frame)
eventTimer.emit("message")
}
}
}
@@ -62,11 +78,19 @@ object BackupRepository {
return outputStream.toByteArray()
}
fun import(data: ByteArray) {
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
val eventTimer = EventTimer()
val stream = ByteArrayInputStream(data)
val frameReader = PlainTextBackupImportStream(stream)
val frameReader = if (plaintext) {
PlainTextBackupReader(inputStreamFactory())
} else {
EncryptedBackupReader(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = selfData.aci,
streamLength = length,
dataStream = inputStreamFactory
)
}
// 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.
@@ -76,6 +100,12 @@ object BackupRepository {
SignalDatabase.distributionLists.clearAllDataForBackupRestore()
SignalDatabase.threads.clearAllDataForBackupRestore()
SignalDatabase.messages.clearAllDataForBackupRestore()
SignalDatabase.attachments.clearAllDataForBackupRestore()
// Add back self after clearing data
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
SignalDatabase.recipients.setProfileSharing(selfId, true)
val backupState = BackupState()
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
@@ -83,7 +113,7 @@ object BackupRepository {
for (frame in frameReader) {
when {
frame.account != null -> {
AccountDataProcessor.import(frame.account)
AccountDataProcessor.import(frame.account, selfId)
eventTimer.emit("account")
}
@@ -118,6 +148,13 @@ object BackupRepository {
Log.d(TAG, "import() ${eventTimer.stop().summary}")
}
data class SelfData(
val aci: ACI,
val pni: PNI,
val e164: String,
val profileKey: ProfileKey
)
}
class BackupState {

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.delete
import org.thoughtcrime.securesms.database.AttachmentTable
fun AttachmentTable.clearAllDataForBackupRestore() {
writableDatabase.delete(AttachmentTable.TABLE_NAME).run()
}

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.core.util.requireBlob
@@ -14,16 +15,16 @@ import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChange
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChange
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.Quote
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
import org.thoughtcrime.securesms.backup.v2.proto.RemoteDeletedMessage
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
import org.thoughtcrime.securesms.backup.v2.proto.SimpleUpdate
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
import org.thoughtcrime.securesms.backup.v2.proto.Text
import org.thoughtcrime.securesms.backup.v2.proto.UpdateMessage
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
@@ -35,6 +36,8 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.api.util.toByteArray
import java.io.Closeable
import java.io.IOException
import java.util.LinkedList
@@ -90,31 +93,31 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
when {
record.remoteDeleted -> builder.remoteDeletedMessage = RemoteDeletedMessage()
MessageTypes.isJoinedType(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.JOINED_SIGNAL))
MessageTypes.isIdentityUpdate(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.IDENTITY_UPDATE))
MessageTypes.isIdentityVerified(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.IDENTITY_VERIFIED))
MessageTypes.isIdentityDefault(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.IDENTITY_DEFAULT))
MessageTypes.isChangeNumber(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.CHANGE_NUMBER))
MessageTypes.isBoostRequest(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.BOOST_REQUEST))
MessageTypes.isEndSessionType(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.END_SESSION))
MessageTypes.isChatSessionRefresh(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.CHAT_SESSION_REFRESH))
MessageTypes.isBadDecryptType(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.BAD_DECRYPT))
MessageTypes.isPaymentsActivated(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.PAYMENTS_ACTIVATED))
MessageTypes.isPaymentsRequestToActivate(record.type) -> builder.updateMessage = UpdateMessage(simpleUpdate = SimpleUpdate(type = SimpleUpdate.Type.PAYMENT_ACTIVATION_REQUEST))
MessageTypes.isExpirationTimerUpdate(record.type) -> builder.updateMessage = UpdateMessage(expirationTimerChange = ExpirationTimerChange((record.expiresIn / 1000).toInt()))
MessageTypes.isJoinedType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.JOINED_SIGNAL))
MessageTypes.isIdentityUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_UPDATE))
MessageTypes.isIdentityVerified(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_VERIFIED))
MessageTypes.isIdentityDefault(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_DEFAULT))
MessageTypes.isChangeNumber(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
MessageTypes.isBoostRequest(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_REQUEST))
MessageTypes.isEndSessionType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.END_SESSION))
MessageTypes.isChatSessionRefresh(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHAT_SESSION_REFRESH))
MessageTypes.isBadDecryptType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BAD_DECRYPT))
MessageTypes.isPaymentsActivated(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENTS_ACTIVATED))
MessageTypes.isPaymentsRequestToActivate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST))
MessageTypes.isExpirationTimerUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate((record.expiresIn / 1000).toInt()))
MessageTypes.isProfileChange(record.type) -> {
builder.updateMessage = UpdateMessage(
builder.updateMessage = ChatUpdateMessage(
profileChange = try {
val decoded: ByteArray = Base64.decode(record.body!!)
val profileChangeDetails = ProfileChangeDetails.ADAPTER.decode(decoded)
if (profileChangeDetails.profileNameChange != null) {
ProfileChange(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue)
ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue)
} else {
ProfileChange()
ProfileChangeChatUpdate()
}
} catch (e: IOException) {
Log.w(TAG, "Profile name change details could not be read", e)
ProfileChange()
ProfileChangeChatUpdate()
}
)
}
@@ -142,9 +145,9 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
chatId = record.threadId
authorId = record.fromRecipientId
dateSent = record.dateSent
dateReceived = record.dateReceived
expireStart = if (record.expireStarted > 0) record.expireStarted else null
expiresIn = if (record.expiresIn > 0) record.expiresIn else null
sealedSender = record.sealedSender
expireStartDate = if (record.expireStarted > 0) record.expireStarted else null
expiresInMs = if (record.expiresIn > 0) record.expiresIn else null
revisions = emptyList()
sms = !MessageTypes.isSecureType(record.type)
@@ -155,7 +158,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
} else {
incoming = ChatItem.IncomingMessageDetails(
dateServerSent = record.dateServer,
sealedSender = record.sealedSender,
dateReceived = record.dateReceived,
read = record.read
)
}
@@ -169,21 +172,21 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
body = this.body!!,
bodyRanges = this.bodyRanges?.toBackupBodyRanges() ?: emptyList()
),
linkPreview = null,
// TODO Link previews!
linkPreview = emptyList(),
longText = null,
reactions = reactionRecords.toBackupReactions()
)
}
private fun BackupMessageRecord.toQuote(): Quote? {
return if (this.quoteTargetSentTimestamp > 0) {
return if (this.quoteTargetSentTimestamp != MessageTable.QUOTE_NOT_PRESENT_ID && this.quoteAuthor > 0) {
// TODO Attachments!
val type = QuoteModel.Type.fromCode(this.quoteType)
Quote(
targetSentTimestamp = this.quoteTargetSentTimestamp,
targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID },
authorId = this.quoteAuthor,
text = this.quoteBody,
originalMessageMissing = this.quoteMissing,
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(),
type = when (type) {
QuoteModel.Type.NORMAL -> Quote.Type.NORMAL
@@ -207,7 +210,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
BackupBodyRange(
start = it.start,
length = it.length,
mentionAci = it.mentionUuid,
mentionAci = it.mentionUuid?.let { UuidUtil.parseOrThrow(it) }?.toByteArray()?.toByteString(),
style = it.style?.toBackupBodyRangeStyle()
)
}
@@ -245,9 +248,9 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
val status: SendStatus.Status = when {
this.viewedReceiptCount > 0 -> SendStatus.Status.VIEWED
this.readReceiptCount > 0 -> SendStatus.Status.READ
this.deliveryReceiptCount > 0 -> SendStatus.Status.DELIVERED
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
@@ -257,7 +260,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
SendStatus(
recipientId = this.toRecipientId,
deliveryStatus = status,
timestamp = this.receiptTimestamp,
lastStatusUpdateTimestamp = this.receiptTimestamp,
sealedSender = this.sealedSender,
networkFailure = this.networkFailureRecipientIds.contains(this.toRecipientId),
identityKeyMismatch = this.identityMismatchRecipientIds.contains(this.toRecipientId)
@@ -271,7 +274,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
recipientId = it.recipientId.toLong(),
deliveryStatus = it.status.toBackupDeliveryStatus(),
sealedSender = it.isUnidentified,
timestamp = it.timestamp,
lastStatusUpdateTimestamp = it.timestamp,
networkFailure = networkFailureRecipientIds.contains(it.recipientId.toLong()),
identityKeyMismatch = identityMismatchRecipientIds.contains(it.recipientId.toLong())
)
@@ -337,9 +340,9 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
quoteType = this.requireInt(MessageTable.QUOTE_TYPE),
originalMessageId = this.requireLong(MessageTable.ORIGINAL_MESSAGE_ID),
latestRevisionId = this.requireLong(MessageTable.LATEST_REVISION_ID),
deliveryReceiptCount = this.requireInt(MessageTable.DELIVERY_RECEIPT_COUNT),
viewedReceiptCount = this.requireInt(MessageTable.VIEWED_RECEIPT_COUNT),
readReceiptCount = this.requireInt(MessageTable.READ_RECEIPT_COUNT),
hasDeliveryReceipt = this.requireBoolean(MessageTable.HAS_DELIVERY_RECEIPT),
viewed = this.requireBoolean(MessageTable.VIEWED_COLUMN),
hasReadReceipt = this.requireBoolean(MessageTable.HAS_READ_RECEIPT),
read = this.requireBoolean(MessageTable.READ),
receiptTimestamp = this.requireLong(MessageTable.RECEIPT_TIMESTAMP),
networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(),
@@ -371,9 +374,9 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
val quoteType: Int,
val originalMessageId: Long,
val latestRevisionId: Long,
val deliveryReceiptCount: Int,
val readReceiptCount: Int,
val viewedReceiptCount: Int,
val hasDeliveryReceipt: Boolean,
val hasReadReceipt: Boolean,
val viewed: Boolean,
val receiptTimestamp: Long,
val read: Boolean,
val networkFailureRecipientIds: Set<Long>,

View File

@@ -14,12 +14,12 @@ import org.signal.core.util.toInt
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.Quote
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
import org.thoughtcrime.securesms.backup.v2.proto.SimpleUpdate
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
import org.thoughtcrime.securesms.backup.v2.proto.UpdateMessage
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.util.UuidUtil
/**
* An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them
@@ -58,9 +59,9 @@ class ChatItemImportInserter(
MessageTable.BODY,
MessageTable.FROM_RECIPIENT_ID,
MessageTable.TO_RECIPIENT_ID,
MessageTable.DELIVERY_RECEIPT_COUNT,
MessageTable.READ_RECEIPT_COUNT,
MessageTable.VIEWED_RECEIPT_COUNT,
MessageTable.HAS_DELIVERY_RECEIPT,
MessageTable.HAS_READ_RECEIPT,
MessageTable.VIEWED_COLUMN,
MessageTable.MISMATCHED_IDENTITIES,
MessageTable.EXPIRES_IN,
MessageTable.EXPIRE_STARTED,
@@ -173,32 +174,32 @@ class ChatItemImportInserter(
contentValues.put(MessageTable.FROM_RECIPIENT_ID, fromRecipientId.serialize())
contentValues.put(MessageTable.TO_RECIPIENT_ID, (if (this.outgoing != null) chatRecipientId else selfId).serialize())
contentValues.put(MessageTable.THREAD_ID, threadId)
contentValues.put(MessageTable.DATE_RECEIVED, this.dateReceived)
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOf { it.timestamp } ?: 0)
contentValues.put(MessageTable.DATE_RECEIVED, this.incoming?.dateReceived ?: this.dateSent)
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOf { it.lastStatusUpdateTimestamp } ?: 0)
contentValues.putNull(MessageTable.LATEST_REVISION_ID)
contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID)
contentValues.put(MessageTable.REVISION_NUMBER, 0)
contentValues.put(MessageTable.EXPIRES_IN, this.expiresIn ?: 0)
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStart ?: 0)
contentValues.put(MessageTable.EXPIRES_IN, this.expiresInMs ?: 0)
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStartDate ?: 0)
if (this.outgoing != null) {
val viewReceiptCount = this.outgoing.sendStatus.count { it.deliveryStatus == SendStatus.Status.VIEWED }
val readReceiptCount = Integer.max(viewReceiptCount, this.outgoing.sendStatus.count { it.deliveryStatus == SendStatus.Status.READ })
val deliveryReceiptCount = Integer.max(readReceiptCount, this.outgoing.sendStatus.count { it.deliveryStatus == SendStatus.Status.DELIVERED })
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 }
contentValues.put(MessageTable.VIEWED_RECEIPT_COUNT, viewReceiptCount)
contentValues.put(MessageTable.READ_RECEIPT_COUNT, readReceiptCount)
contentValues.put(MessageTable.DELIVERY_RECEIPT_COUNT, deliveryReceiptCount)
contentValues.put(MessageTable.VIEWED_COLUMN, viewed.toInt())
contentValues.put(MessageTable.HAS_READ_RECEIPT, hasReadReceipt.toInt())
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, hasDeliveryReceipt.toInt())
contentValues.put(MessageTable.UNIDENTIFIED, this.outgoing.sendStatus.count { it.sealedSender })
contentValues.put(MessageTable.READ, 1)
contentValues.addNetworkFailures(this, backupState)
contentValues.addIdentityKeyMismatches(this, backupState)
} else {
contentValues.put(MessageTable.VIEWED_RECEIPT_COUNT, 0)
contentValues.put(MessageTable.READ_RECEIPT_COUNT, 0)
contentValues.put(MessageTable.DELIVERY_RECEIPT_COUNT, 0)
contentValues.put(MessageTable.UNIDENTIFIED, this.incoming?.sealedSender?.toInt() ?: 0)
contentValues.put(MessageTable.VIEWED_COLUMN, 0)
contentValues.put(MessageTable.HAS_READ_RECEIPT, 0)
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, 0)
contentValues.put(MessageTable.UNIDENTIFIED, this.sealedSender?.toInt())
contentValues.put(MessageTable.READ, this.incoming?.read?.toInt() ?: 0)
}
@@ -264,7 +265,7 @@ class ChatItemImportInserter(
GroupReceiptTable.MMS_ID to messageId,
GroupReceiptTable.RECIPIENT_ID to recipientId.serialize(),
GroupReceiptTable.STATUS to sendStatus.deliveryStatus.toLocalSendStatus(),
GroupReceiptTable.TIMESTAMP to sendStatus.timestamp,
GroupReceiptTable.TIMESTAMP to sendStatus.lastStatusUpdateTimestamp,
GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender
)
} else {
@@ -308,27 +309,28 @@ class ChatItemImportInserter(
}
}
private fun ContentValues.addUpdateMessage(updateMessage: UpdateMessage) {
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage) {
var typeFlags: Long = 0
when {
updateMessage.simpleUpdate != null -> {
typeFlags = when (updateMessage.simpleUpdate.type) {
SimpleUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE
SimpleUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT
SimpleUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT
SimpleUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT
SimpleUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
SimpleUpdate.Type.BOOST_REQUEST -> MessageTypes.BOOST_REQUEST_TYPE
SimpleUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT
SimpleUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT
SimpleUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE
SimpleUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
SimpleUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST
SimpleChatUpdate.Type.UNKNOWN -> 0
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
SimpleChatUpdate.Type.BOOST_REQUEST -> MessageTypes.BOOST_REQUEST_TYPE
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT
SimpleChatUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST
}
}
updateMessage.expirationTimerChange != null -> {
typeFlags = MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
put(MessageTable.EXPIRES_IN, updateMessage.expirationTimerChange.expiresIn.toLong() * 1000)
put(MessageTable.EXPIRES_IN, updateMessage.expirationTimerChange.expiresInMs.toLong())
}
updateMessage.profileChange != null -> {
typeFlags = MessageTypes.PROFILE_CHANGE_TYPE
@@ -341,13 +343,13 @@ class ChatItemImportInserter(
}
private fun ContentValues.addQuote(quote: Quote) {
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp)
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
this.put(MessageTable.QUOTE_AUTHOR, backupState.backupToLocalRecipientId[quote.authorId]!!.serialize())
this.put(MessageTable.QUOTE_BODY, quote.text)
this.put(MessageTable.QUOTE_TYPE, quote.type.toLocalQuoteType())
this.put(MessageTable.QUOTE_BODY_RANGES, quote.bodyRanges.toLocalBodyRanges()?.encode())
// TODO quote attachments
this.put(MessageTable.QUOTE_MISSING, quote.originalMessageMissing.toInt())
this.put(MessageTable.QUOTE_MISSING, (quote.targetSentTimestamp == null).toInt())
}
private fun Quote.Type.toLocalQuoteType(): Int {
@@ -398,7 +400,7 @@ class ChatItemImportInserter(
return BodyRangeList(
ranges = this.map { bodyRange ->
BodyRangeList.BodyRange(
mentionUuid = bodyRange.mentionAci,
mentionUuid = bodyRange.mentionAci?.let { UuidUtil.fromByteString(it) }?.toString(),
style = bodyRange.style?.let {
when (bodyRange.style) {
BodyRange.Style.BOLD -> BodyRangeList.BodyRange.Style.BOLD
@@ -418,6 +420,7 @@ 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

View File

@@ -58,7 +58,6 @@ fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
distributionId = record.distributionId.asUuid().toByteArray().toByteString(),
allowReplies = record.allowsReplies,
deletionTimestamp = record.deletedAtTimestamp,
isUnknown = record.isUnknown,
privacyMode = record.privacyMode.toBackupPrivacyMode(),
memberRecipientIds = record.members.map { it.toLong() }
)
@@ -81,7 +80,6 @@ fun DistributionListTables.restoreFromBackup(dlist: BackupDistributionList, back
allowsReplies = dlist.allowReplies,
deletionTimestamp = dlist.deletionTimestamp,
storageId = null,
isUnknown = dlist.isUnknown,
privacyMode = dlist.privacyMode.toLocalPrivacyMode()
)!!
@@ -108,6 +106,7 @@ private fun DistributionListPrivacyMode.toBackupPrivacyMode(): BackupDistributio
private fun BackupDistributionList.PrivacyMode.toLocalPrivacyMode(): DistributionListPrivacyMode {
return when (this) {
BackupDistributionList.PrivacyMode.UNKNOWN -> DistributionListPrivacyMode.ALL
BackupDistributionList.PrivacyMode.ONLY_WITH -> DistributionListPrivacyMode.ONLY_WITH
BackupDistributionList.PrivacyMode.ALL -> DistributionListPrivacyMode.ALL
BackupDistributionList.PrivacyMode.ALL_EXCEPT -> DistributionListPrivacyMode.ALL_EXCEPT

View File

@@ -40,9 +40,9 @@ fun MessageTable.getMessagesForBackup(): ChatItemExportIterator {
MessageTable.QUOTE_TYPE,
MessageTable.ORIGINAL_MESSAGE_ID,
MessageTable.LATEST_REVISION_ID,
MessageTable.DELIVERY_RECEIPT_COUNT,
MessageTable.READ_RECEIPT_COUNT,
MessageTable.VIEWED_RECEIPT_COUNT,
MessageTable.HAS_DELIVERY_RECEIPT,
MessageTable.HAS_READ_RECEIPT,
MessageTable.VIEWED_COLUMN,
MessageTable.RECEIPT_TIMESTAMP,
MessageTable.READ,
MessageTable.NETWORK_FAILURES,

View File

@@ -127,9 +127,7 @@ fun RecipientTable.restoreRecipientFromBackup(recipient: BackupRecipient, backup
/**
* Given [AccountData], this will insert the necessary data for the local user into the [RecipientTable].
*/
fun RecipientTable.restoreSelfFromBackup(accountData: AccountData) {
val self = Recipient.trustedPush(ACI.parseOrThrow(accountData.aci.toByteArray()), PNI.parseOrNull(accountData.pni.toByteArray()), accountData.e164.toString())
fun RecipientTable.restoreSelfFromBackup(accountData: AccountData, selfId: RecipientId) {
val values = ContentValues().apply {
put(RecipientTable.PROFILE_GIVEN_NAME, accountData.givenName.nullIfBlank())
put(RecipientTable.PROFILE_FAMILY_NAME, accountData.familyName.nullIfBlank())
@@ -152,7 +150,7 @@ fun RecipientTable.restoreSelfFromBackup(accountData: AccountData) {
writableDatabase
.update(RecipientTable.TABLE_NAME)
.values(values)
.where("${RecipientTable.ID} = ?", self.id)
.where("${RecipientTable.ID} = ?", selfId)
.run()
}
@@ -181,7 +179,7 @@ private fun RecipientTable.restoreContactFromBackup(contact: Contact): Recipient
RecipientTable.HIDDEN to contact.hidden,
RecipientTable.PROFILE_FAMILY_NAME to contact.profileFamilyName.nullIfBlank(),
RecipientTable.PROFILE_GIVEN_NAME to contact.profileGivenName.nullIfBlank(),
RecipientTable.PROFILE_JOINED_NAME to contact.profileJoinedName.nullIfBlank(),
RecipientTable.PROFILE_JOINED_NAME to ProfileName.fromParts(contact.profileGivenName.nullIfBlank(), contact.profileFamilyName.nullIfBlank()).toString().nullIfBlank(),
RecipientTable.PROFILE_KEY to if (profileKey == null) null else Base64.encodeWithPadding(profileKey),
RecipientTable.PROFILE_SHARING to contact.profileSharing.toInt(),
RecipientTable.REGISTERED to contact.registered.toLocalRegisteredState().id,
@@ -205,12 +203,12 @@ private fun Contact.toLocalExtras(): RecipientExtras {
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
*/
class BackupContactIterator(private val cursor: Cursor, private val selfId: Long) : Iterator<BackupRecipient>, Closeable {
class BackupContactIterator(private val cursor: Cursor, private val selfId: Long) : Iterator<BackupRecipient?>, Closeable {
override fun hasNext(): Boolean {
return cursor.count > 0 && !cursor.isLast
}
override fun next(): BackupRecipient {
override fun next(): BackupRecipient? {
if (!cursor.moveToNext()) {
throw NoSuchElementException()
}
@@ -225,10 +223,15 @@ class BackupContactIterator(private val cursor: Cursor, private val selfId: Long
val aci = ACI.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN))
val pni = PNI.parseOrNull(cursor.requireString(RecipientTable.PNI_COLUMN))
val e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong()
val registeredState = RecipientTable.RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED))
val profileKey = cursor.requireString(RecipientTable.PROFILE_KEY)
val extras = RecipientTableCursorUtil.getExtras(cursor)
if (aci == null && pni == null && e164 == null) {
return null
}
return BackupRecipient(
id = id,
contact = Contact(
@@ -244,7 +247,6 @@ class BackupContactIterator(private val cursor: Cursor, private val selfId: Long
profileSharing = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
profileGivenName = cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME).nullIfBlank(),
profileFamilyName = cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME).nullIfBlank(),
profileJoinedName = cursor.requireString(RecipientTable.PROFILE_JOINED_NAME).nullIfBlank(),
hideStory = extras?.hideStory() ?: false
)
)

View File

@@ -7,13 +7,20 @@ package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.select
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.recipients.RecipientId
import java.io.Closeable
private val TAG = Log.tag(ThreadTable::class.java)
fun ThreadTable.getThreadsForBackup(): ChatIterator {
val cursor = readableDatabase
.select(
@@ -35,6 +42,17 @@ fun ThreadTable.clearAllDataForBackupRestore() {
clearCache()
}
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId): Long? {
return writableDatabase
.insertInto(ThreadTable.TABLE_NAME)
.values(
ThreadTable.RECIPIENT_ID to recipientId.serialize(),
ThreadTable.PINNED to chat.pinnedOrder,
ThreadTable.ARCHIVED to chat.archived.toInt()
)
.run()
}
class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
override fun hasNext(): Boolean {
return cursor.count > 0 && !cursor.isLast
@@ -49,8 +67,8 @@ class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
id = cursor.requireLong(ThreadTable.ID),
recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID),
archived = cursor.requireBoolean(ThreadTable.ARCHIVED),
pinned = cursor.requireBoolean(ThreadTable.PINNED),
expirationTimer = cursor.requireLong(ThreadTable.EXPIRES_IN)
pinnedOrder = cursor.requireInt(ThreadTable.PINNED),
expirationTimerMs = cursor.requireLong(ThreadTable.EXPIRES_IN)
)
}

View File

@@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.ProfileUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
@@ -35,21 +36,11 @@ object AccountDataProcessor {
val self = Recipient.self().fresh()
val record = recipients.getRecordForSync(self.id)
val pniIdentityKey = SignalStore.account().pniIdentityKey
val aciIdentityKey = SignalStore.account().aciIdentityKey
val subscriber: Subscriber? = SignalStore.donationsValues().getSubscriber()
emitter.emit(
Frame(
account = AccountData(
aci = SignalStore.account().aci!!.toByteString(),
pni = SignalStore.account().pni!!.toByteString(),
e164 = SignalStore.account().e164!!.toLong(),
pniIdentityPrivateKey = pniIdentityKey.privateKey.serialize().toByteString(),
pniIdentityPublicKey = pniIdentityKey.publicKey.serialize().toByteString(),
aciIdentityPrivateKey = aciIdentityKey.privateKey.serialize().toByteString(),
aciIdentityPublicKey = aciIdentityKey.publicKey.serialize().toByteString(),
profileKey = self.profileKey?.toByteString() ?: EMPTY,
givenName = self.profileName.givenName,
familyName = self.profileName.familyName,
@@ -60,14 +51,12 @@ object AccountDataProcessor {
subscriberCurrencyCode = subscriber?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
accountSettings = AccountData.AccountSettings(
storyViewReceiptsEnabled = SignalStore.storyValues().viewedReceiptsEnabled,
hasReadOnboardingStory = SignalStore.storyValues().userHasViewedOnboardingStory || SignalStore.storyValues().userHasReadOnboardingStory,
noteToSelfArchived = record != null && record.syncExtras.isArchived,
noteToSelfMarkedUnread = record != null && record.syncExtras.isForcedUnread,
typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context),
readReceipts = TextSecurePreferences.isReadReceiptsEnabled(context),
sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context),
linkPreviews = SignalStore.settings().isLinkPreviewsEnabled,
unlistedPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isUnlisted,
notDiscoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isUnlisted,
phoneNumberSharingMode = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
preferContactAvatars = SignalStore.settings().isPreferSystemContactPhotos,
universalExpireTimer = SignalStore.settings().universalExpireTimer,
@@ -84,16 +73,12 @@ object AccountDataProcessor {
)
}
fun import(accountData: AccountData) {
SignalStore.account().restoreAciIdentityKeyFromBackup(accountData.aciIdentityPublicKey.toByteArray(), accountData.aciIdentityPrivateKey.toByteArray())
SignalStore.account().restorePniIdentityKeyFromBackup(accountData.pniIdentityPublicKey.toByteArray(), accountData.pniIdentityPrivateKey.toByteArray())
recipients.restoreSelfFromBackup(accountData)
fun import(accountData: AccountData, selfId: RecipientId) {
recipients.restoreSelfFromBackup(accountData, selfId)
SignalStore.account().setRegistered(true)
val context = ApplicationDependencies.getApplication()
val settings = accountData.accountSettings
if (settings != null) {
@@ -101,7 +86,7 @@ object AccountDataProcessor {
TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators)
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators)
SignalStore.settings().isLinkPreviewsEnabled = settings.linkPreviews
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = if (settings.unlistedPhoneNumber) PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED else PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED else PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode()
SignalStore.settings().isPreferSystemContactPhotos = settings.preferContactAvatars
SignalStore.settings().universalExpireTimer = settings.universalExpireTimer
@@ -111,7 +96,6 @@ object AccountDataProcessor {
SignalStore.storyValues().userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy
SignalStore.storyValues().userHasViewedOnboardingStory = settings.hasViewedOnboardingStory
SignalStore.storyValues().isFeatureDisabled = settings.storiesDisabled
SignalStore.storyValues().userHasReadOnboardingStory = settings.hasReadOnboardingStory
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
SignalStore.storyValues().viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts

View File

@@ -8,12 +8,12 @@ package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.database.getThreadsForBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreFromBackup
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.Collections
object ChatBackupProcessor {
val TAG = Log.tag(ChatBackupProcessor::class.java)
@@ -27,26 +27,18 @@ object ChatBackupProcessor {
}
fun import(chat: Chat, backupState: BackupState) {
// TODO Perf can be improved here by doing a single insert instead of insert + multiple updates
val recipientId: RecipientId? = backupState.backupToLocalRecipientId[chat.recipientId]
if (recipientId == null) {
Log.w(TAG, "Missing recipient for chat ${chat.id}")
return
}
if (recipientId != null) {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(org.thoughtcrime.securesms.recipients.Recipient.resolved(recipientId))
if (chat.archived) {
SignalDatabase.threads.archiveConversation(threadId)
}
if (chat.pinned) {
SignalDatabase.threads.pinConversations(Collections.singleton(threadId))
}
SignalDatabase.threads.restoreFromBackup(chat, recipientId)?.let { threadId ->
backupState.chatIdToLocalRecipientId[chat.id] = recipientId
backupState.chatIdToLocalThreadId[chat.id] = threadId
backupState.chatIdToBackupRecipientId[chat.id] = chat.recipientId
} else {
Log.w(TAG, "Recipient doesnt exist with id $recipientId")
}
// TODO there's several fields in the chat that actually need to be restored on the recipient table
}
}

View File

@@ -27,7 +27,9 @@ object RecipientBackupProcessor {
SignalDatabase.recipients.getContactsForBackup(selfId).use { reader ->
for (backupRecipient in reader) {
emitter.emit(Frame(recipient = backupRecipient))
if (backupRecipient != null) {
emitter.emit(Frame(recipient = backupRecipient))
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.libsignal.protocol.kdf.HKDF
import java.io.FilterOutputStream
import java.io.OutputStream
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class BackupEncryptedOutputStream(key: ByteArray, backupId: ByteArray, wrapped: OutputStream) : FilterOutputStream(wrapped) {
val cipher: Cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val mac: Mac = Mac.getInstance("HmacSHA256")
var finalMac: ByteArray? = null
init {
if (key.size != 32) {
throw IllegalArgumentException("Key must be 32 bytes!")
}
if (backupId.size != 16) {
throw IllegalArgumentException("BackupId must be 32 bytes!")
}
val extendedKey = HKDF.deriveSecrets(key, backupId, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80)
val macKey = extendedKey.copyOfRange(0, 32)
val cipherKey = extendedKey.copyOfRange(32, 64)
val iv = extendedKey.copyOfRange(64, 80)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
}
override fun write(b: Int) {
throw UnsupportedOperationException()
}
override fun write(data: ByteArray) {
write(data, 0, data.size)
}
override fun write(data: ByteArray, off: Int, len: Int) {
cipher.update(data, off, len)?.let { ciphertext ->
mac.update(ciphertext)
super.write(ciphertext)
}
}
override fun flush() {
cipher.doFinal()?.let { ciphertext ->
mac.update(ciphertext)
super.write(ciphertext)
}
finalMac = mac.doFinal()
super.flush()
}
override fun close() {
flush()
super.close()
}
fun getMac(): ByteArray {
return finalMac ?: throw IllegalStateException("Mac not yet available! You must call flush() before asking for the mac.")
}
}

View File

@@ -7,6 +7,6 @@ package org.thoughtcrime.securesms.backup.v2.stream
import org.thoughtcrime.securesms.backup.v2.proto.Frame
interface BackupExportStream {
interface BackupExportWriter : AutoCloseable {
fun write(frame: Frame)
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.readFully
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.signal.core.util.stream.MacInputStream
import org.signal.core.util.stream.TruncatingInputStream
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.util.zip.GZIPInputStream
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Provides the ability to read backup frames in a streaming fashion from a target [InputStream].
* As it's being read, it will be both decrypted and uncompressed. Specifically, the data is decrypted,
* that decrypted data is gunzipped, then that data is read as frames.
*/
class EncryptedBackupReader(
key: BackupKey,
aci: ACI,
streamLength: Long,
dataStream: () -> InputStream
) : Iterator<Frame>, AutoCloseable {
var next: Frame? = null
val stream: InputStream
init {
val keyMaterial = key.deriveSecrets(aci)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
}
validateMac(keyMaterial.macKey, streamLength, dataStream())
stream = GZIPInputStream(
CipherInputStream(
TruncatingInputStream(
wrapped = dataStream(),
maxBytes = streamLength - MAC_SIZE
),
cipher
)
)
next = read()
}
override fun hasNext(): Boolean {
return next != null
}
override fun next(): Frame {
next?.let { out ->
next = read()
return out
} ?: throw NoSuchElementException()
}
private fun read(): Frame? {
try {
val length = stream.readVarInt32().also { if (it < 0) return null }
val frameBytes: ByteArray = stream.readNBytesOrThrow(length)
return Frame.ADAPTER.decode(frameBytes)
} catch (e: EOFException) {
return null
}
}
override fun close() {
stream.close()
}
companion object {
const val MAC_SIZE = 32
fun validateMac(macKey: ByteArray, streamLength: Long, dataStream: InputStream) {
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(macKey, "HmacSHA256"))
}
val macStream = MacInputStream(
wrapped = TruncatingInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
mac = mac
)
macStream.readFully(false)
val calculatedMac = macStream.mac.doFinal()
val expectedMac = dataStream.readNBytesOrThrow(MAC_SIZE)
if (!calculatedMac.contentEquals(expectedMac)) {
throw IOException("Invalid MAC!")
}
}
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.stream.MacOutputStream
import org.signal.core.util.writeVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.io.IOException
import java.io.OutputStream
import java.util.zip.GZIPOutputStream
import javax.crypto.Cipher
import javax.crypto.CipherOutputStream
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Provides the ability to write backup frames in a streaming fashion to a target [OutputStream].
* As it's being written, it will be both encrypted and compressed. Specifically, the backup frames
* are gzipped, that gzipped data is encrypted, and then an HMAC of the encrypted data is appended
* to the end of the [outputStream].
*/
class EncryptedBackupWriter(
key: BackupKey,
aci: ACI,
private val outputStream: OutputStream,
private val append: (ByteArray) -> Unit
) : BackupExportWriter {
private val mainStream: GZIPOutputStream
private val macStream: MacOutputStream
init {
val keyMaterial = key.deriveSecrets(aci)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
}
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(keyMaterial.macKey, "HmacSHA256"))
}
macStream = MacOutputStream(outputStream, mac)
mainStream = GZIPOutputStream(
CipherOutputStream(
macStream,
cipher
)
)
}
@Throws(IOException::class)
override fun write(frame: Frame) {
val frameBytes: ByteArray = frame.encode()
mainStream.writeVarInt32(frameBytes.size)
mainStream.write(frameBytes)
}
@Throws(IOException::class)
override fun close() {
// We need to close the main stream in order for the gzip and all the cipher operations to fully finish before
// we can calculate the MAC. Unfortunately flush()/finish() is not sufficient. So we have to defer to the
// caller to append the bytes to the end of the data however they see fit (like appending to a file).
mainStream.close()
val mac = macStream.mac.doFinal()
append(mac)
}
}

View File

@@ -5,8 +5,8 @@
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.Conversions
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import java.io.EOFException
import java.io.InputStream
@@ -14,7 +14,7 @@ import java.io.InputStream
/**
* Reads a plaintext backup import stream one frame at a time.
*/
class PlainTextBackupImportStream(val inputStream: InputStream) : BackupImportStream, Iterator<Frame> {
class PlainTextBackupReader(val inputStream: InputStream) : Iterator<Frame> {
var next: Frame? = null
@@ -33,15 +33,12 @@ class PlainTextBackupImportStream(val inputStream: InputStream) : BackupImportSt
} ?: throw NoSuchElementException()
}
override fun read(): Frame? {
private fun read(): Frame? {
try {
val lengthBytes: ByteArray = inputStream.readNBytesOrThrow(4)
val length = Conversions.byteArrayToInt(lengthBytes)
val length = inputStream.readVarInt32().also { if (it < 0) return null }
val frameBytes: ByteArray = inputStream.readNBytesOrThrow(length)
val frame: Frame = Frame.ADAPTER.decode(frameBytes)
return frame
return Frame.ADAPTER.decode(frameBytes)
} catch (e: EOFException) {
return null
}

View File

@@ -5,7 +5,7 @@
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.Conversions
import org.signal.core.util.writeVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import java.io.IOException
import java.io.OutputStream
@@ -13,14 +13,17 @@ import java.io.OutputStream
/**
* Writes backup frames to the wrapped stream in plain text. Only for testing!
*/
class PlainTextBackupExportStream(private val outputStream: OutputStream) : BackupExportStream {
class PlainTextBackupWriter(private val outputStream: OutputStream) : BackupExportWriter {
@Throws(IOException::class)
override fun write(frame: Frame) {
val frameBytes: ByteArray = frame.encode()
val lengthBytes: ByteArray = Conversions.intToByteArray(frameBytes.size)
outputStream.write(lengthBytes)
outputStream.writeVarInt32(frameBytes.size)
outputStream.write(frameBytes)
}
override fun close() {
outputStream.close()
}
}

View File

@@ -5,23 +5,70 @@
package org.thoughtcrime.securesms.components.settings.app.internal.backup
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.getLength
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupState
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState
import org.thoughtcrime.securesms.compose.ComposeFragment
class InternalBackupPlaygroundFragment : ComposeFragment() {
val viewModel: InternalBackupPlaygroundViewModel by viewModels()
private val viewModel: InternalBackupPlaygroundViewModel by viewModels()
private lateinit var exportFileLauncher: ActivityResultLauncher<Intent>
private lateinit var importFileLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
exportFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
requireContext().contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(viewModel.backupData!!)
Toast.makeText(requireContext(), "Saved successfully", Toast.LENGTH_SHORT).show()
} ?: Toast.makeText(requireContext(), "Failed to open output stream", Toast.LENGTH_SHORT).show()
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
}
}
importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
requireContext().contentResolver.getLength(uri)?.let { length ->
viewModel.import(length) { requireContext().contentResolver.openInputStream(uri)!! }
}
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
}
}
}
@Composable
override fun FragmentContent() {
@@ -30,22 +77,65 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
Screen(
state = state,
onExportClicked = { viewModel.export() },
onImportClicked = { viewModel.import() }
onImportMemoryClicked = { viewModel.import() },
onImportFileClicked = {
val intent = Intent().apply {
action = Intent.ACTION_GET_CONTENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
}
importFileLauncher.launch(intent)
},
onPlaintextClicked = { viewModel.onPlaintextToggled() },
onSaveToDiskClicked = {
val intent = Intent().apply {
action = Intent.ACTION_CREATE_DOCUMENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin")
}
exportFileLauncher.launch(intent)
}
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
}
}
@Composable
fun Screen(
state: ScreenState,
onExportClicked: () -> Unit = {},
onImportClicked: () -> Unit = {}
onImportMemoryClicked: () -> Unit = {},
onImportFileClicked: () -> Unit = {},
onPlaintextClicked: () -> Unit = {},
onSaveToDiskClicked: () -> Unit = {}
) {
Surface {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
StateLabel(text = "Plaintext?")
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = state.plaintext,
onCheckedChange = { onPlaintextClicked() }
)
}
Spacer(modifier = Modifier.height(8.dp))
Buttons.LargePrimary(
onClick = onExportClicked,
enabled = !state.backupState.inProgress
@@ -53,17 +143,92 @@ fun Screen(
Text("Export")
}
Buttons.LargeTonal(
onClick = onImportClicked,
onClick = onImportMemoryClicked,
enabled = state.backupState == BackupState.EXPORT_DONE
) {
Text("Import")
Text("Import from memory")
}
Buttons.LargeTonal(
onClick = onImportFileClicked
) {
Text("Import from file")
}
Spacer(modifier = Modifier.height(16.dp))
when (state.backupState) {
BackupState.NONE -> {
StateLabel("")
}
BackupState.EXPORT_IN_PROGRESS -> {
StateLabel("Export in progress...")
}
BackupState.EXPORT_DONE -> {
StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, or you can save it to a file.")
Spacer(modifier = Modifier.height(8.dp))
Buttons.MediumTonal(onClick = onSaveToDiskClicked) {
Text("Save to file")
}
}
BackupState.IMPORT_IN_PROGRESS -> {
StateLabel("Import in progress...")
}
}
}
}
}
@Preview
@Composable
private fun StateLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center
)
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PreviewScreen() {
Screen(state = ScreenState(backupState = BackupState.NONE))
SignalTheme {
Surface {
Screen(state = ScreenState(backupState = BackupState.NONE, plaintext = false))
}
}
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PreviewScreenExportInProgress() {
SignalTheme {
Surface {
Screen(state = ScreenState(backupState = BackupState.EXPORT_IN_PROGRESS, plaintext = false))
}
}
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PreviewScreenExportDone() {
SignalTheme {
Surface {
Screen(state = ScreenState(backupState = BackupState.EXPORT_DONE, plaintext = false))
}
}
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PreviewScreenImportInProgress() {
SignalTheme {
Surface {
Screen(state = ScreenState(backupState = BackupState.IMPORT_IN_PROGRESS, plaintext = false))
}
}
}

View File

@@ -14,7 +14,11 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.recipients.Recipient
import java.io.ByteArrayInputStream
import java.io.InputStream
class InternalBackupPlaygroundViewModel : ViewModel() {
@@ -22,13 +26,14 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val disposables = CompositeDisposable()
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE))
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE, plaintext = false))
val state: State<ScreenState> = _state
fun export() {
_state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext
disposables += Single.fromCallable { BackupRepository.export() }
disposables += Single.fromCallable { BackupRepository.export(plaintext = plaintext) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { data ->
@@ -40,8 +45,12 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
fun import() {
backupData?.let {
_state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext
disposables += Single.fromCallable { BackupRepository.import(it) }
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable { BackupRepository.import(it.size.toLong(), { ByteArrayInputStream(it) }, selfData, plaintext = plaintext) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { nothing ->
@@ -51,12 +60,33 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
fun import(length: Long, inputStreamFactory: () -> InputStream) {
_state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = plaintext) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { nothing ->
backupData = null
_state.value = _state.value.copy(backupState = BackupState.NONE)
}
}
fun onPlaintextToggled() {
_state.value = _state.value.copy(plaintext = !_state.value.plaintext)
}
override fun onCleared() {
disposables.clear()
}
data class ScreenState(
val backupState: BackupState
val backupState: BackupState,
val plaintext: Boolean
)
enum class BackupState(val inProgress: Boolean = false) {

View File

@@ -207,6 +207,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val ORIGINAL_MESSAGE_ID = "original_message_id"
const val REVISION_NUMBER = "revision_number"
const val QUOTE_NOT_PRESENT_ID = 0L
const val QUOTE_TARGET_MISSING_ID = -1L
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -2316,7 +2319,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val quoteAttachments: List<Attachment> = associatedAttachments.filter { it.isQuote }.toList()
val quoteMentions: List<Mention> = parseQuoteMentions(cursor)
val quoteBodyRanges: BodyRangeList? = parseQuoteBodyRanges(cursor)
val quote: QuoteModel? = if (quoteId > 0 && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || quoteAttachments.isNotEmpty())) {
val quote: QuoteModel? = if (quoteId != QUOTE_NOT_PRESENT_ID && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || quoteAttachments.isNotEmpty())) {
QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText ?: "", quoteMissing, quoteAttachments, quoteMentions, QuoteModel.Type.fromCode(quoteType), quoteBodyRanges)
} else {
null
@@ -5119,7 +5122,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val quoteAttachments: List<Attachment> = attachments.filter { it.isQuote }
val quoteDeck = SlideDeck(quoteAttachments)
return if (quoteId > 0 && quoteAuthor > 0) {
return if (quoteId != QUOTE_NOT_PRESENT_ID && quoteAuthor > 0) {
if (quoteText != null && (quoteMentions.isNotEmpty() || bodyRanges != null)) {
val updated: UpdatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions)
val styledText = SpannableString(updated.body)

View File

@@ -688,7 +688,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
val foundRecords = queries.flatMap { query ->
readableDatabase.query(TABLE_NAME, null, query.where, query.whereArgs, null, null, null).readToList { cursor ->
getRecord(context, cursor)
RecipientTableCursorUtil.getRecord(context, cursor)
}
}

View File

@@ -200,7 +200,6 @@ public final class FeatureFlags {
@SuppressWarnings("MismatchedQueryAndUpdateOfCollection")
@VisibleForTesting
static final Map<String, Object> FORCED_VALUES = new HashMap<String, Object>() {{
put(INTERNAL_USER, true);
}};
/**