mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 08:09:12 +01:00
Update local backup v2 support.
This commit is contained in:
committed by
jeffrey-signal
parent
71b15d269e
commit
d9ecab5240
@@ -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.
|
||||
*
|
||||
|
||||
@@ -16,7 +16,6 @@ class LocalBackupV2Event(val type: Type, val count: Long = 0, val estimatedTotal
|
||||
CHAT_FOLDER,
|
||||
PROGRESS_MESSAGE,
|
||||
PROGRESS_ATTACHMENT,
|
||||
PROGRESS_VERIFYING,
|
||||
FINISHED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user