Update local backup v2 support.

This commit is contained in:
Cody Henthorne
2025-12-18 16:33:30 -05:00
committed by jeffrey-signal
parent 71b15d269e
commit d9ecab5240
55 changed files with 2291 additions and 274 deletions

View File

@@ -85,7 +85,6 @@ import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.util.ArchiveAttachmentInfo
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
@@ -770,24 +769,20 @@ object BackupRepository {
append = { main.write(it) }
)
val maxBufferSize = 10_000
var totalAttachmentCount = 0
val attachmentInfos: MutableSet<ArchiveAttachmentInfo> = mutableSetOf()
export(
currentTime = System.currentTimeMillis(),
isLocal = true,
writer = writer,
progressEmitter = localBackupProgressEmitter,
cancellationSignal = cancellationSignal,
forTransfer = false,
backupMode = BackupMode.LOCAL,
extraFrameOperation = null,
messageInclusionCutoffTime = 0
) { dbSnapshot ->
val localArchivableAttachments = dbSnapshot
.attachmentTable
.getLocalArchivableAttachments()
.associateBy { MediaName.fromPlaintextHashAndRemoteKey(it.plaintextHash, it.remoteKey) }
.associateBy { MediaName.forLocalBackupFilename(it.plaintextHash, it.localBackupKey.key) }
localBackupProgressEmitter.onAttachment(0, localArchivableAttachments.size.toLong())
@@ -834,7 +829,7 @@ object BackupRepository {
currentTime = currentTime,
isLocal = false,
writer = writer,
forTransfer = false,
backupMode = BackupMode.REMOTE,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraFrameOperation = extraFrameOperation,
@@ -865,7 +860,7 @@ object BackupRepository {
currentTime = currentTime,
isLocal = false,
writer = writer,
forTransfer = true,
backupMode = BackupMode.LINK_SYNC,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraFrameOperation = null,
@@ -882,7 +877,6 @@ object BackupRepository {
messageBackupKey: MessageBackupKey = SignalStore.backup.messageBackupKey,
plaintext: Boolean = false,
currentTime: Long = System.currentTimeMillis(),
forTransfer: Boolean = false,
progressEmitter: ExportProgressListener? = null,
cancellationSignal: () -> Boolean = { false }
) {
@@ -901,7 +895,7 @@ object BackupRepository {
currentTime = currentTime,
isLocal = false,
writer = writer,
forTransfer = forTransfer,
backupMode = BackupMode.REMOTE,
progressEmitter = progressEmitter,
cancellationSignal = cancellationSignal,
extraFrameOperation = null,
@@ -925,7 +919,7 @@ object BackupRepository {
currentTime: Long,
isLocal: Boolean,
writer: BackupExportWriter,
forTransfer: Boolean,
backupMode: BackupMode,
messageInclusionCutoffTime: Long,
progressEmitter: ExportProgressListener?,
cancellationSignal: () -> Boolean,
@@ -945,7 +939,7 @@ object BackupRepository {
val selfAci = signalStoreSnapshot.accountValues.aci!!
val selfRecipientId = dbSnapshot.recipientTable.getByAci(selfAci).get().toLong().let { RecipientId.from(it) }
val exportState = ExportState(backupTime = currentTime, forTransfer = forTransfer, selfRecipientId = selfRecipientId)
val exportState = ExportState(backupTime = currentTime, backupMode = backupMode, selfRecipientId = selfRecipientId)
var frameCount = 0L
@@ -2435,7 +2429,7 @@ data class ArchivedMediaObject(val mediaId: String, val cdn: Int)
class ExportState(
val backupTime: Long,
val forTransfer: Boolean,
val backupMode: BackupMode,
val selfRecipientId: RecipientId
) {
val recipientIds: MutableSet<Long> = hashSetOf()
@@ -2507,6 +2501,18 @@ sealed interface RestoreTimestampResult {
data object Failure : RestoreTimestampResult
}
enum class BackupMode {
REMOTE,
LINK_SYNC,
LOCAL;
val isLinkAndSync: Boolean
get() = this == LINK_SYNC
val isLocalBackup: Boolean
get() = this == LOCAL
}
/**
* Iterator that reads values from the given cursor. Expects that REMOTE_DIGEST is present and non-null, and ARCHIVE_CDN is present.
*

View File

@@ -16,7 +16,6 @@ class LocalBackupV2Event(val type: Type, val count: Long = 0, val estimatedTotal
CHAT_FOLDER,
PROGRESS_MESSAGE,
PROGRESS_ATTACHMENT,
PROGRESS_VERIFYING,
FINISHED
}
}

View File

@@ -67,7 +67,8 @@ class ChatArchiveExporter(private val cursor: Cursor, private val db: SignalData
db = db,
chatColors = chatColors,
chatColorId = customChatColorsId,
chatWallpaper = chatWallpaper
chatWallpaper = chatWallpaper,
backupMode = exportState.backupMode
)
)
}

View File

@@ -36,6 +36,7 @@ import org.signal.core.util.requireString
import org.signal.core.util.toByteArray
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ExportOddities
import org.thoughtcrime.securesms.backup.v2.ExportSkips
import org.thoughtcrime.securesms.backup.v2.ExportState
@@ -176,7 +177,7 @@ class ChatItemArchiveExporter(
return buffer.remove()
}
val extraData = fetchExtraMessageData(db, records.keys)
val extraData = fetchExtraMessageData(db = db, messageIds = records.keys)
eventTimer.emit("extra-data")
transformTimer.emit("ignore")
@@ -368,7 +369,7 @@ class ChatItemArchiveExporter(
}
!record.sharedContacts.isNullOrEmpty() -> {
builder.contactMessage = record.toRemoteContactMessage(reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[id]) ?: continue
builder.contactMessage = record.toRemoteContactMessage(reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[id], backupMode = exportState.backupMode) ?: continue
transformTimer.emit("contact")
}
@@ -382,7 +383,7 @@ class ChatItemArchiveExporter(
Log.w(TAG, ExportSkips.directStoryReplyInNoteToSelf(record.dateSent))
continue
}
builder.directStoryReplyMessage = record.toRemoteDirectStoryReplyMessage(reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[record.id]) ?: continue
builder.directStoryReplyMessage = record.toRemoteDirectStoryReplyMessage(reactionRecords = extraData.reactionsById[id], attachments = extraData.attachmentsById[record.id], backupMode = exportState.backupMode) ?: continue
transformTimer.emit("story")
}
@@ -430,7 +431,7 @@ class ChatItemArchiveExporter(
val sticker = attachments?.firstOrNull { dbAttachment -> dbAttachment.isSticker }
if (sticker?.stickerLocator != null) {
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, reactions = extraData.reactionsById[id])
builder.stickerMessage = sticker.toRemoteStickerMessage(sentTimestamp = record.dateSent, reactions = extraData.reactionsById[id], exportState.backupMode)
} else {
val standardMessage = record.toRemoteStandardMessage(
exportState = exportState,
@@ -521,7 +522,7 @@ class ChatItemArchiveExporter(
val attachmentsFuture = executor.submitTyped {
extraDataTimer.timeEvent("attachments") {
db.attachmentTable.getAttachmentsForMessages(messageIds, excludeTranscodingQuotes = true)
db.attachmentTable.getAttachmentsForMessagesArchive(messageIds)
}
}
@@ -642,9 +643,9 @@ private fun BackupMessageRecord.toBasicChatItemBuilder(selfRecipientId: Recipien
if (!MessageTypes.isExpirationTimerUpdate(record.type) && builder.expiresInMs != null && builder.expireStartDate != null) {
val cutoffDuration = ChatItemArchiveExporter.EXPIRATION_CUTOFF.inWholeMilliseconds
val expiresAt = builder.expireStartDate!! + builder.expiresInMs!!
val threshold = if (exportState.forTransfer) backupStartTime else backupStartTime + cutoffDuration
val threshold = if (exportState.backupMode.isLinkAndSync) backupStartTime else backupStartTime + cutoffDuration
if (expiresAt < threshold || (builder.expiresInMs!! <= cutoffDuration && !exportState.forTransfer)) {
if (expiresAt < threshold || (builder.expiresInMs!! <= cutoffDuration && !exportState.backupMode.isLinkAndSync)) {
Log.w(TAG, ExportSkips.messageExpiresTooSoon(record.dateSent))
return null
}
@@ -954,22 +955,22 @@ private fun BackupMessageRecord.toRemoteLinkPreviews(attachments: List<DatabaseA
return emptyList()
}
private fun LinkPreview.toRemoteLinkPreview(): org.thoughtcrime.securesms.backup.v2.proto.LinkPreview {
private fun LinkPreview.toRemoteLinkPreview(backupMode: BackupMode): org.thoughtcrime.securesms.backup.v2.proto.LinkPreview {
return org.thoughtcrime.securesms.backup.v2.proto.LinkPreview(
url = url,
title = title.nullIfEmpty(),
image = (thumbnail.orNull() as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer,
image = (thumbnail.orNull() as? DatabaseAttachment)?.toRemoteMessageAttachment(backupMode = backupMode)?.pointer,
description = description.nullIfEmpty(),
date = date.clampToValidBackupRange()
)
}
private fun BackupMessageRecord.toRemoteViewOnceMessage(exportState: ExportState, reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): ViewOnceMessage {
val attachment: MessageAttachment? = if (exportState.forTransfer) {
val attachment: MessageAttachment? = if (exportState.backupMode.isLinkAndSync) {
attachments
?.firstOrNull()
?.takeUnless { !it.hasData && it.size == 0L && it.remoteDigest == null && it.width == 0 && it.height == 0 && it.blurHash == null }
?.toRemoteMessageAttachment()
?.toRemoteMessageAttachment(backupMode = exportState.backupMode)
} else {
null
}
@@ -980,13 +981,13 @@ private fun BackupMessageRecord.toRemoteViewOnceMessage(exportState: ExportState
)
}
private fun BackupMessageRecord.toRemoteContactMessage(reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): ContactMessage? {
private fun BackupMessageRecord.toRemoteContactMessage(reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?, backupMode: BackupMode): ContactMessage? {
val sharedContact = toRemoteSharedContact(attachments) ?: return null
return ContactMessage(
contact = ContactAttachment(
name = sharedContact.name.toRemote(),
avatar = (sharedContact.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment()?.pointer,
avatar = (sharedContact.avatar?.attachment as? DatabaseAttachment)?.toRemoteMessageAttachment(backupMode = backupMode)?.pointer,
organization = sharedContact.organization ?: "",
number = sharedContact.phoneNumbers.mapNotNull { phone ->
ContactAttachment.Phone(
@@ -1067,7 +1068,7 @@ private fun Contact.PostalAddress.Type.toRemote(): ContactAttachment.PostalAddre
}
}
private fun BackupMessageRecord.toRemoteDirectStoryReplyMessage(reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): DirectStoryReplyMessage? {
private fun BackupMessageRecord.toRemoteDirectStoryReplyMessage(reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?, backupMode: BackupMode): DirectStoryReplyMessage? {
if (this.body.isNullOrBlank()) {
Log.w(TAG, ExportSkips.directStoryReplyHasNoBody(this.dateSent))
return null
@@ -1089,7 +1090,7 @@ private fun BackupMessageRecord.toRemoteDirectStoryReplyMessage(reactionRecords:
body = bodyText,
bodyRanges = this.bodyRanges?.toRemoteBodyRanges(this.dateSent) ?: emptyList()
),
longText = longTextAttachment?.toRemoteFilePointer()
longText = longTextAttachment?.toRemoteFilePointer(backupMode = backupMode)
)
} else {
null
@@ -1121,9 +1122,9 @@ private fun BackupMessageRecord.toRemoteStandardMessage(exportState: ExportState
return StandardMessage(
quote = this.toRemoteQuote(exportState, quotedAttachments),
text = text.takeUnless { hasVoiceNote },
attachments = messageAttachments.toRemoteAttachments().withFixedVoiceNotes(textPresent = text != null || longTextAttachment != null),
linkPreview = linkPreviews.map { it.toRemoteLinkPreview() },
longText = longTextAttachment?.toRemoteFilePointer(),
attachments = messageAttachments.toRemoteAttachments(exportState.backupMode).withFixedVoiceNotes(textPresent = text != null || longTextAttachment != null),
linkPreview = linkPreviews.map { it.toRemoteLinkPreview(exportState.backupMode) },
longText = longTextAttachment?.toRemoteFilePointer(backupMode = exportState.backupMode),
reactions = reactionRecords.toRemote()
)
}
@@ -1194,7 +1195,7 @@ private fun BackupMessageRecord.toRemoteQuote(exportState: ExportState, attachme
val attachments = if (remoteType == Quote.Type.VIEW_ONCE) {
emptyList()
} else {
attachments?.toRemoteQuoteAttachments() ?: emptyList()
attachments?.toRemoteQuoteAttachments(exportState.backupMode) ?: emptyList()
}
if (remoteType == Quote.Type.NORMAL && body == null && attachments.isEmpty()) {
@@ -1250,7 +1251,7 @@ private fun PollRecord.toRemotePollMessage(reactionRecords: List<ReactionRecord>
)
}
private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, reactions: List<ReactionRecord>?): StickerMessage? {
private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, reactions: List<ReactionRecord>?, backupMode: BackupMode): StickerMessage? {
val stickerLocator = this.stickerLocator!!
val packId = try {
@@ -1273,18 +1274,19 @@ private fun DatabaseAttachment.toRemoteStickerMessage(sentTimestamp: Long, react
packKey = packKey.toByteString(),
stickerId = stickerLocator.stickerId,
emoji = stickerLocator.emoji,
data_ = this.toRemoteMessageAttachment().pointer
data_ = this.toRemoteMessageAttachment(backupMode = backupMode).pointer
),
reactions = reactions.toRemote()
)
}
private fun List<DatabaseAttachment>.toRemoteQuoteAttachments(): List<Quote.QuotedAttachment> {
private fun List<DatabaseAttachment>.toRemoteQuoteAttachments(backupMode: BackupMode): List<Quote.QuotedAttachment> {
return this.map { attachment ->
Quote.QuotedAttachment(
contentType = attachment.quoteTargetContentType,
fileName = attachment.fileName,
thumbnail = attachment.toRemoteMessageAttachment(
backupMode = backupMode,
flagOverride = MessageAttachment.Flag.NONE,
contentTypeOverride = attachment.contentType
)
@@ -1292,8 +1294,8 @@ private fun List<DatabaseAttachment>.toRemoteQuoteAttachments(): List<Quote.Quot
}
}
private fun DatabaseAttachment.toRemoteMessageAttachment(flagOverride: MessageAttachment.Flag? = null, contentTypeOverride: String? = null): MessageAttachment {
val pointer = this.toRemoteFilePointer(contentTypeOverride)
private fun DatabaseAttachment.toRemoteMessageAttachment(backupMode: BackupMode, flagOverride: MessageAttachment.Flag? = null, contentTypeOverride: String? = null): MessageAttachment {
val pointer = this.toRemoteFilePointer(contentTypeOverride, backupMode)
return MessageAttachment(
pointer = pointer,
wasDownloaded = (this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE) && pointer.locatorInfo?.plaintextHash != null,
@@ -1312,9 +1314,9 @@ private fun DatabaseAttachment.toRemoteMessageAttachment(flagOverride: MessageAt
)
}
private fun List<DatabaseAttachment>.toRemoteAttachments(): List<MessageAttachment> {
private fun List<DatabaseAttachment>.toRemoteAttachments(backupMode: BackupMode): List<MessageAttachment> {
return this.map { attachment ->
attachment.toRemoteMessageAttachment()
attachment.toRemoteMessageAttachment(backupMode = backupMode)
}
}

View File

@@ -5,17 +5,22 @@
package org.thoughtcrime.securesms.backup.v2.local
import okio.ByteString.Companion.toByteString
import org.greenrobot.eventbus.EventBus
import org.signal.core.models.backup.BackupId
import org.signal.core.models.backup.MediaName
import org.signal.core.util.Base64
import org.signal.core.util.Stopwatch
import org.signal.core.util.StreamUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.readFully
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
import org.thoughtcrime.securesms.backup.v2.local.proto.FilesFrame
import org.thoughtcrime.securesms.backup.v2.local.proto.Metadata
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
@@ -23,8 +28,12 @@ import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Collections
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
typealias ArchiveResult = org.signal.core.util.Result<Unit, LocalArchiver.FailureCause>
typealias ArchiveResult = org.signal.core.util.Result<LocalArchiver.ArchiveSuccess, LocalArchiver.ArchiveFailure>
typealias RestoreResult = org.signal.core.util.Result<LocalArchiver.RestoreSuccess, LocalArchiver.RestoreFailure>
/**
* Handle importing and exporting folder-based archives using backupv2 format.
@@ -34,6 +43,8 @@ object LocalArchiver {
private val TAG = Log.tag(LocalArchiver::class)
private const val VERSION = 1
private const val MAX_CREATE_FAILURES = 10
/**
* Export archive to the provided [snapshotFileSystem] and store new files in [filesFileSystem].
*/
@@ -44,12 +55,15 @@ object LocalArchiver {
var mainStream: OutputStream? = null
var filesStream: OutputStream? = null
val createFailures: MutableSet<AttachmentId> = Collections.synchronizedSet(HashSet())
val readWriteFailures: MutableSet<AttachmentId> = Collections.synchronizedSet(HashSet())
try {
metadataStream = snapshotFileSystem.metadataOutputStream() ?: return ArchiveResult.failure(FailureCause.METADATA_STREAM)
metadataStream.use { it.write(Metadata(VERSION).encode()) }
metadataStream = snapshotFileSystem.metadataOutputStream() ?: return ArchiveResult.failure(ArchiveFailure.MetadataStream)
metadataStream.use { it.write(Metadata(version = VERSION, backupId = getEncryptedBackupId()).encode()) }
stopwatch.split("metadata")
mainStream = snapshotFileSystem.mainOutputStream() ?: return ArchiveResult.failure(FailureCause.MAIN_STREAM)
mainStream = snapshotFileSystem.mainOutputStream() ?: return ArchiveResult.failure(ArchiveFailure.MainStream)
Log.i(TAG, "Listing all current files")
val allFiles = filesFileSystem.allFiles()
@@ -59,11 +73,11 @@ object LocalArchiver {
Log.i(TAG, "Starting frame export")
BackupRepository.exportForLocalBackup(mainStream, LocalExportProgressListener(), cancellationSignal) { attachment, source ->
if (cancellationSignal()) {
if (cancellationSignal() || createFailures.size > MAX_CREATE_FAILURES) {
return@exportForLocalBackup
}
val mediaName = MediaName.fromPlaintextHashAndRemoteKey(attachment.plaintextHash, attachment.remoteKey)
val mediaName = MediaName.forLocalBackupFilename(attachment.plaintextHash, attachment.localBackupKey.key)
mediaNames.add(mediaName)
@@ -73,23 +87,21 @@ object LocalArchiver {
}
source()?.use { sourceStream ->
val combinedKey = Base64.decode(attachment.remoteKey)
val destination: OutputStream? = filesFileSystem.fileOutputStream(mediaName)
if (destination == null) {
Log.w(TAG, "Unable to create output file for attachment")
// todo [local-backup] should we abort here?
Log.w(TAG, "Unable to create output file for ${attachment.attachmentId}")
createFailures.add(attachment.attachmentId)
} else {
// todo [local-backup] but deal with attachment disappearing/deleted by normal app use
try {
PaddingInputStream(sourceStream, attachment.size).use { input ->
AttachmentCipherOutputStream(combinedKey, null, destination).use { output ->
StreamUtil.copy(input, output)
AttachmentCipherOutputStream(attachment.localBackupKey.key, null, destination).use { output ->
StreamUtil.copy(input, output, false, false)
}
}
} catch (e: IOException) {
Log.w(TAG, "Unable to save attachment", e)
// todo [local-backup] should we abort here?
Log.w(TAG, "Unable to save ${attachment.attachmentId}", e)
readWriteFailures.add(attachment.attachmentId)
}
}
}
@@ -97,7 +109,11 @@ object LocalArchiver {
}
stopwatch.split("frames-and-files")
filesStream = snapshotFileSystem.filesOutputStream() ?: return ArchiveResult.failure(FailureCause.FILES_STREAM)
if (createFailures.size > MAX_CREATE_FAILURES) {
return ArchiveResult.failure(ArchiveFailure.TooManyCreateFailures(createFailures))
}
filesStream = snapshotFileSystem.filesOutputStream() ?: return ArchiveResult.failure(ArchiveFailure.FilesStream)
ArchivedFilesWriter(filesStream).use { writer ->
mediaNames.forEach { name -> writer.write(FilesFrame(mediaName = name.name)) }
}
@@ -109,22 +125,56 @@ object LocalArchiver {
}
if (cancellationSignal()) {
return ArchiveResult.failure(FailureCause.CANCELLED)
return ArchiveResult.failure(ArchiveFailure.Cancelled)
}
return ArchiveResult.success(Unit)
return if (createFailures.isNotEmpty() || readWriteFailures.isNotEmpty()) {
ArchiveResult.success(ArchiveSuccess.PartialSuccess(createFailures, readWriteFailures))
} else {
ArchiveResult.success(ArchiveSuccess.FullSuccess)
}
}
private fun getEncryptedBackupId(): Metadata.EncryptedBackupId {
val metadataKey = SignalStore.backup.messageBackupKey.deriveLocalBackupMetadataKey()
val iv = Util.getSecretBytes(12)
val backupId = SignalStore.backup.messageBackupKey.deriveBackupId(SignalStore.account.requireAci())
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
val cipherText = cipher.doFinal(backupId.value)
return Metadata.EncryptedBackupId(iv = iv.toByteString(), encryptedId = cipherText.toByteString())
}
/**
* Import archive data from a folder on the system. Does not restore attachments.
*/
fun import(snapshotFileSystem: SnapshotFileSystem, selfData: BackupRepository.SelfData): ArchiveResult {
fun import(snapshotFileSystem: SnapshotFileSystem, selfData: BackupRepository.SelfData): RestoreResult {
var metadataStream: InputStream? = null
try {
metadataStream = snapshotFileSystem.metadataInputStream() ?: return ArchiveResult.failure(FailureCause.METADATA_STREAM)
metadataStream = snapshotFileSystem.metadataInputStream() ?: return RestoreResult.failure(RestoreFailure.MetadataStream)
val metadata = Metadata.ADAPTER.decode(metadataStream.readFully(autoClose = false))
val mainStreamLength = snapshotFileSystem.mainLength() ?: return ArchiveResult.failure(FailureCause.MAIN_STREAM)
if (metadata.version > VERSION) {
Log.w(TAG, "Local backup version does not match, bailing supported: $VERSION backup: ${metadata.version}")
return RestoreResult.failure(RestoreFailure.VersionMismatch(metadata.version, VERSION))
}
if (metadata.backupId == null) {
Log.w(TAG, "Local backup metadata missing encrypted backup id")
return RestoreResult.failure(RestoreFailure.BackupIdMissing)
}
val backupId = decryptBackupId(metadata.backupId)
if (!backupId.value.contentEquals(SignalStore.backup.messageBackupKey.deriveBackupId(SignalStore.account.requireAci()).value)) {
Log.w(TAG, "Local backup metadata backup id does not match derived backup id, likely from another account")
return RestoreResult.failure(RestoreFailure.BackupIdMismatch)
}
val mainStreamLength = snapshotFileSystem.mainLength() ?: return ArchiveResult.failure(RestoreFailure.MainStream)
BackupRepository.importLocal(
mainStreamFactory = { snapshotFileSystem.mainInputStream()!! },
@@ -135,18 +185,52 @@ object LocalArchiver {
metadataStream?.close()
}
return ArchiveResult.success(Unit)
return RestoreResult.success(RestoreSuccess.FullSuccess)
}
private fun decryptBackupId(encryptedBackupId: Metadata.EncryptedBackupId): BackupId {
val metadataKey = SignalStore.backup.messageBackupKey.deriveLocalBackupMetadataKey()
val iv = encryptedBackupId.iv.toByteArray()
val backupIdCipher = encryptedBackupId.encryptedId.toByteArray()
val cipher = Cipher.getInstance("AES/CTR/NoPadding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(metadataKey, "AES"), IvParameterSpec(iv))
val plaintext = cipher.doFinal(backupIdCipher)
return BackupId(plaintext)
}
private val AttachmentTable.LocalArchivableAttachment.cipherLength: Long
get() = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size))
enum class FailureCause {
METADATA_STREAM, MAIN_STREAM, FILES_STREAM, CANCELLED
sealed interface ArchiveSuccess {
data object FullSuccess : ArchiveSuccess
data class PartialSuccess(val createFailures: Set<AttachmentId>, val readWriteFailures: Set<AttachmentId>) : ArchiveSuccess
}
sealed interface ArchiveFailure {
data object MetadataStream : ArchiveFailure
data object MainStream : ArchiveFailure
data object FilesStream : ArchiveFailure
data object Cancelled : ArchiveFailure
data class TooManyCreateFailures(val attachmentId: Set<AttachmentId>) : ArchiveFailure
}
sealed interface RestoreSuccess {
data object FullSuccess : RestoreSuccess
}
sealed interface RestoreFailure {
data object MetadataStream : RestoreFailure
data object MainStream : RestoreFailure
data object Cancelled : RestoreFailure
data object BackupIdMissing : RestoreFailure
data object BackupIdMismatch : RestoreFailure
data class VersionMismatch(val backupVersion: Int, val supportedVersion: Int) : RestoreFailure
}
private class LocalExportProgressListener : BackupRepository.ExportProgressListener {
private var lastAttachmentUpdate: Long = 0
private var lastVerboseUpdate: Long = 0
override fun onAccount() {
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_ACCOUNT))
@@ -177,15 +261,16 @@ object LocalArchiver {
}
override fun onMessage(currentProgress: Long, approximateCount: Long) {
if (currentProgress == 0L) {
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_MESSAGE))
if (lastVerboseUpdate > System.currentTimeMillis() || lastVerboseUpdate + 1000 < System.currentTimeMillis() || currentProgress >= approximateCount) {
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_MESSAGE, currentProgress, approximateCount))
lastVerboseUpdate = System.currentTimeMillis()
}
}
override fun onAttachment(currentProgress: Long, totalCount: Long) {
if (lastAttachmentUpdate > System.currentTimeMillis() || lastAttachmentUpdate + 1000 < System.currentTimeMillis() || currentProgress >= totalCount) {
if (lastVerboseUpdate > System.currentTimeMillis() || lastVerboseUpdate + 1000 < System.currentTimeMillis() || currentProgress >= totalCount) {
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_ATTACHMENT, currentProgress, totalCount))
lastAttachmentUpdate = System.currentTimeMillis()
lastVerboseUpdate = System.currentTimeMillis()
}
}
}

View File

@@ -142,7 +142,8 @@ object AccountDataArchiveProcessor {
db = db,
chatColors = chatColors,
chatColorId = chatColors?.id?.takeIf { it.isValid(exportState) } ?: ChatColors.Id.NotSet,
chatWallpaper = chatWallpaper
chatWallpaper = chatWallpaper,
backupMode = exportState.backupMode
)
),
donationSubscriberData = donationSubscriber?.toSubscriberData(signalStore.inAppPaymentValues.isDonationSubscriptionManuallyCancelled()),

View File

@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
@@ -78,7 +79,8 @@ fun FilePointer?.toLocalAttachment(
quote = quote,
quoteTargetContentType = quoteTargetContentType,
uuid = UuidUtil.fromByteStringOrNull(uuid),
fileName = fileName
fileName = fileName,
localBackupKey = this.locatorInfo.localKey?.toByteArray()
)
}
AttachmentType.TRANSIT -> {
@@ -133,10 +135,7 @@ fun FilePointer?.toLocalAttachment(
}
}
/**
* @param mediaArchiveEnabled True if this user has enable media backup, otherwise false.
*/
fun DatabaseAttachment.toRemoteFilePointer(contentTypeOverride: String? = null): FilePointer {
fun DatabaseAttachment.toRemoteFilePointer(contentTypeOverride: String? = null, backupMode: BackupMode): FilePointer {
val builder = FilePointer.Builder()
builder.contentType = contentTypeOverride ?: this.contentType?.takeUnless { it.isBlank() }
builder.incrementalMac = this.incrementalDigest?.takeIf { it.isNotEmpty() && this.incrementalMacChunkSize > 0 }?.toByteString()
@@ -146,12 +145,12 @@ fun DatabaseAttachment.toRemoteFilePointer(contentTypeOverride: String? = null):
builder.height = this.height.takeIf { it > 0 }
builder.caption = this.caption
builder.blurHash = this.blurHash?.hash
builder.locatorInfo = this.toLocatorInfo()
builder.locatorInfo = this.toLocatorInfo(backupMode)
return builder.build()
}
fun DatabaseAttachment.toLocatorInfo(): FilePointer.LocatorInfo {
fun DatabaseAttachment.toLocatorInfo(backupMode: BackupMode): FilePointer.LocatorInfo {
val attachmentType = this.toRemoteAttachmentType()
if (attachmentType == AttachmentType.INVALID) {
@@ -183,6 +182,14 @@ fun DatabaseAttachment.toLocatorInfo(): FilePointer.LocatorInfo {
AttachmentType.INVALID -> Unit
}
if (backupMode.isLocalBackup && this.dataHash != null && this.metadata?.localBackupKey != null) {
if (locatorBuilder.plaintextHash == null) {
locatorBuilder.plaintextHash = Base64.decode(this.dataHash).toByteString()
}
locatorBuilder.localKey = this.metadata.localBackupKey.toByteString()
}
return locatorBuilder.build()
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.util
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.BackupMode
import org.thoughtcrime.securesms.backup.v2.ImportState
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
@@ -34,7 +35,8 @@ object ChatStyleConverter {
db: SignalDatabase,
chatColors: ChatColors?,
chatColorId: ChatColors.Id,
chatWallpaper: Wallpaper?
chatWallpaper: Wallpaper?,
backupMode: BackupMode
): ChatStyle? {
if (chatColors == null && chatWallpaper == null) {
return null
@@ -72,7 +74,7 @@ object ChatStyleConverter {
chatStyleBuilder.wallpaperPreset = chatWallpaper.linearGradient.toRemoteWallpaperPreset()
}
chatWallpaper.file_ != null -> {
chatStyleBuilder.wallpaperPhoto = chatWallpaper.file_.toFilePointer(db)
chatStyleBuilder.wallpaperPhoto = chatWallpaper.file_.toFilePointer(db, backupMode)
}
}
@@ -251,10 +253,10 @@ private fun Wallpaper.LinearGradient.toRemoteWallpaperPreset(): ChatStyle.Wallpa
}
}
private fun Wallpaper.File.toFilePointer(db: SignalDatabase): FilePointer? {
private fun Wallpaper.File.toFilePointer(db: SignalDatabase, backupMode: BackupMode): FilePointer? {
val attachmentId: AttachmentId = UriUtil.parseOrNull(this.uri)?.let { PartUriParser(it).partId } ?: return null
val attachment = db.attachmentTable.getAttachment(attachmentId)
return attachment?.toRemoteFilePointer()
return attachment?.toRemoteFilePointer(backupMode = backupMode)
}
private fun ChatStyle.Builder.hasBubbleColorSet(): Boolean {