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

@@ -25,6 +25,9 @@ class ArchivedAttachment : Attachment {
@JvmField
val plaintextHash: ByteArray
@JvmField
val localBackupKey: ByteArray?
constructor(
contentType: String?,
size: Long,
@@ -47,7 +50,8 @@ class ArchivedAttachment : Attachment {
quote: Boolean,
quoteTargetContentType: String?,
uuid: UUID?,
fileName: String?
fileName: String?,
localBackupKey: ByteArray?
) : super(
contentType = contentType ?: "",
quote = quote,
@@ -77,17 +81,20 @@ class ArchivedAttachment : Attachment {
) {
this.archiveCdn = archiveCdn
this.plaintextHash = plaintextHash
this.localBackupKey = localBackupKey
}
constructor(parcel: Parcel) : super(parcel) {
archiveCdn = parcel.readInt().takeIf { it != NO_ARCHIVE_CDN }
plaintextHash = parcel.createByteArray()!!
localBackupKey = parcel.createByteArray()
}
override fun writeToParcel(dest: Parcel, flags: Int) {
super.writeToParcel(dest, flags)
dest.writeInt(archiveCdn ?: NO_ARCHIVE_CDN)
dest.writeByteArray(plaintextHash)
dest.writeByteArray(localBackupKey)
}
override val uri: Uri? = null

View File

@@ -18,7 +18,9 @@ object AttachmentCreator : Parcelable.Creator<Attachment> {
POINTER(PointerAttachment::class.java, "pointer"),
TOMBSTONE(TombstoneAttachment::class.java, "tombstone"),
URI(UriAttachment::class.java, "uri"),
ARCHIVED(ArchivedAttachment::class.java, "archived")
ARCHIVED(ArchivedAttachment::class.java, "archived"),
LOCAL_STICKER(LocalStickerAttachment::class.java, "local_sticker"),
WALLPAPER(WallpaperAttachment::class.java, "wallpaper")
}
@JvmStatic
@@ -36,6 +38,8 @@ object AttachmentCreator : Parcelable.Creator<Attachment> {
Subclass.TOMBSTONE -> TombstoneAttachment(source)
Subclass.URI -> UriAttachment(source)
Subclass.ARCHIVED -> ArchivedAttachment(source)
Subclass.LOCAL_STICKER -> LocalStickerAttachment(source)
Subclass.WALLPAPER -> WallpaperAttachment(source)
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.parcelize.RawValue
/**
* Metadata for a specific attachment, specifically per data file. So there can be a
* many-to-one relationship from attachments to metadata.
*/
@Parcelize
class AttachmentMetadata(
val localBackupKey: @RawValue LocalBackupKey?
) : Parcelable

View File

@@ -39,6 +39,10 @@ class DatabaseAttachment : Attachment {
@JvmField
val archiveTransferState: AttachmentTable.ArchiveTransferState
/** Metadata for this attachment, if null, no attempt was made to load the metadata and does not imply there is none */
@JvmField
val metadata: AttachmentMetadata?
private val hasThumbnail: Boolean
val displayOrder: Int
@@ -76,7 +80,8 @@ class DatabaseAttachment : Attachment {
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
archiveTransferState: AttachmentTable.ArchiveTransferState,
uuid: UUID?,
quoteTargetContentType: String?
quoteTargetContentType: String?,
metadata: AttachmentMetadata?
) : super(
contentType = contentType,
transferState = transferProgress,
@@ -112,6 +117,7 @@ class DatabaseAttachment : Attachment {
this.archiveCdn = archiveCdn
this.thumbnailRestoreState = thumbnailRestoreState
this.archiveTransferState = archiveTransferState
this.metadata = metadata
}
constructor(parcel: Parcel) : super(parcel) {
@@ -124,6 +130,7 @@ class DatabaseAttachment : Attachment {
archiveCdn = parcel.readInt().takeIf { it != NO_ARCHIVE_CDN }
thumbnailRestoreState = AttachmentTable.ThumbnailRestoreState.deserialize(parcel.readInt())
archiveTransferState = AttachmentTable.ArchiveTransferState.deserialize(parcel.readInt())
metadata = ParcelCompat.readParcelable(parcel, AttachmentMetadata::class.java.classLoader, AttachmentMetadata::class.java)
}
override fun writeToParcel(dest: Parcel, flags: Int) {
@@ -137,6 +144,7 @@ class DatabaseAttachment : Attachment {
dest.writeInt(archiveCdn ?: NO_ARCHIVE_CDN)
dest.writeInt(thumbnailRestoreState.value)
dest.writeInt(archiveTransferState.value)
dest.writeParcelable(metadata, 0)
}
override val uri: Uri?

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import okio.ByteString
import okio.ByteString.Companion.toByteString
/**
* Combined key used to encrypt/decrypt attachments for local backups.
*/
@JvmInline
value class LocalBackupKey(val key: ByteArray) {
fun toByteString(): ByteString {
return key.toByteString()
}
}

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.attachments
import android.os.Parcel
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.util.MediaUtil
@@ -12,34 +13,39 @@ import org.thoughtcrime.securesms.util.MediaUtil
/**
* A basically-empty [Attachment] that is solely used for inserting an attachment into the [AttachmentTable].
*/
class WallpaperAttachment() : Attachment(
contentType = MediaUtil.IMAGE_WEBP,
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
size = 0,
fileName = null,
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteDigest = null,
incrementalDigest = null,
fastPreflightId = null,
voiceNote = false,
borderless = false,
videoGif = false,
width = 0,
height = 0,
incrementalMacChunkSize = 0,
quote = false,
quoteTargetContentType = null,
uploadTimestamp = 0,
caption = null,
stickerLocator = null,
blurHash = null,
audioHash = null,
transformProperties = TransformProperties.empty(),
uuid = null
) {
class WallpaperAttachment : Attachment {
override val uri = null
override val publicUri = null
override val thumbnailUri = null
constructor() : super(
contentType = MediaUtil.IMAGE_WEBP,
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
size = 0,
fileName = null,
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteDigest = null,
incrementalDigest = null,
fastPreflightId = null,
voiceNote = false,
borderless = false,
videoGif = false,
width = 0,
height = 0,
incrementalMacChunkSize = 0,
quote = false,
quoteTargetContentType = null,
uploadTimestamp = 0,
caption = null,
stickerLocator = null,
blurHash = null,
audioHash = null,
transformProperties = TransformProperties.empty(),
uuid = null
)
@Suppress("unused")
constructor(parcel: Parcel) : super(parcel)
}

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 {

View File

@@ -104,6 +104,7 @@ class BackupsSettingsFragment : ComposeFragment() {
}
},
onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) },
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
)
}
@@ -115,6 +116,7 @@ private fun BackupsSettingsContent(
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {},
onNewOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
) {
Scaffolds.Settings(
@@ -232,6 +234,16 @@ private fun BackupsSettingsContent(
onClick = onOnDeviceBackupsRowClick
)
}
if (backupsSettingsState.showNewLocalBackup) {
item {
Rows.TextRow(
text = "INTERNAL ONLY - New Local Backup",
label = "Use new local backup format",
onClick = onNewOnDeviceBackupsRowClick
)
}
}
}
}
}

View File

@@ -17,5 +17,6 @@ data class BackupsSettingsState(
val backupState: BackupState,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null
val backupTierInternalOverride: MessageBackupTier? = null,
val showNewLocalBackup: Boolean = false
)

View File

@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.time.Duration.Companion.milliseconds
class BackupsSettingsViewModel : ViewModel() {
@@ -45,7 +46,8 @@ class BackupsSettingsViewModel : ViewModel() {
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = Environment.IS_STAGING,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride,
showNewLocalBackup = RemoteConfig.internalUser || Environment.IS_NIGHTLY
)
}
}

View File

@@ -0,0 +1,276 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.StorageUtil
import org.thoughtcrime.securesms.util.formatHours
import java.time.LocalTime
import java.util.Locale
/**
* App settings internal screen for enabling and creating new local backups.
*/
class InternalNewLocalBackupCreateFragment : ComposeFragment() {
private val TAG = Log.tag(InternalNewLocalBackupCreateFragment::class)
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
private var createStatus by mutableStateOf("None")
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
chooseBackupLocationLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
handleBackupLocationSelected(result.data!!.data!!)
} else {
Log.w(TAG, "Backup location selection cancelled or failed")
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: LocalBackupV2Event) {
createStatus = "${event.type}: ${event.count} / ${event.estimatedTotalCount}"
}
@Composable
override fun FragmentContent() {
val context = LocalContext.current
val backupsEnabled by SignalStore.backup.newLocalBackupsEnabledFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsEnabled)
val selectedDirectory by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
val lastBackupTime by SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsLastBackupTime)
val lastBackupTimeString = remember(lastBackupTime) { calculateLastBackupTimeString(context, lastBackupTime) }
val backupTime = remember { LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(requireContext()) }
InternalLocalBackupScreen(
backupsEnabled = backupsEnabled,
selectedDirectory = selectedDirectory,
lastBackupTimeString = lastBackupTimeString,
backupTime = backupTime,
createStatus = createStatus,
callback = CallbackImpl()
)
}
private fun launchBackupDirectoryPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
val latestDirectory = SignalStore.settings.latestSignalBackupDirectory
if (latestDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, latestDirectory)
}
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Launching backup directory picker")
chooseBackupLocationLauncher.launch(intent)
} catch (e: Exception) {
Log.w(TAG, "Failed to launch backup directory picker", e)
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
private fun handleBackupLocationSelected(uri: Uri) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
}
private fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
return if (lastBackupTimestamp > 0) {
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
context,
Locale.getDefault(),
lastBackupTimestamp
)
if (relativeTime.isRelative) {
relativeTime.value
} else {
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
val time = relativeTime.value
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
}
} else {
context.getString(R.string.RemoteBackupsSettingsFragment__never)
}
}
private inner class CallbackImpl : Callback {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onToggleBackupsClick(enabled: Boolean) {
SignalStore.backup.newLocalBackupsEnabled = enabled
if (enabled) {
LocalBackupListener.schedule(requireContext())
}
}
override fun onSelectDirectoryClick() {
launchBackupDirectoryPicker()
}
override fun onEnqueueBackupClick() {
createStatus = "Starting..."
LocalBackupJob.enqueueArchive(false)
}
}
}
private interface Callback {
fun onNavigationClick()
fun onToggleBackupsClick(enabled: Boolean)
fun onSelectDirectoryClick()
fun onEnqueueBackupClick()
object Empty : Callback {
override fun onNavigationClick() = Unit
override fun onToggleBackupsClick(enabled: Boolean) = Unit
override fun onSelectDirectoryClick() = Unit
override fun onEnqueueBackupClick() = Unit
}
}
@Composable
private fun InternalLocalBackupScreen(
backupsEnabled: Boolean = false,
selectedDirectory: String? = null,
lastBackupTimeString: String = "Never",
backupTime: String = "Unknown",
createStatus: String = "None",
callback: Callback
) {
Scaffolds.Settings(
title = "New Local Backups",
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = callback::onNavigationClick
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
item {
Rows.ToggleRow(
checked = backupsEnabled,
text = "Enable New Local Backups",
label = if (backupsEnabled) "Backups are enabled" else "Backups are disabled",
onCheckChanged = callback::onToggleBackupsClick
)
}
item {
Rows.TextRow(
text = "Last Backup",
label = lastBackupTimeString
)
}
item {
Rows.TextRow(
text = "Backup Schedule Time (same as v1)",
label = backupTime
)
}
item {
Rows.TextRow(
text = "Select Backup Directory",
label = selectedDirectory ?: "No directory selected",
onClick = callback::onSelectDirectoryClick
)
}
item {
Rows.TextRow(
text = "Create Backup Now",
label = "Enqueue LocalArchiveJob",
onClick = callback::onEnqueueBackupClick
)
}
item {
Rows.TextRow(
text = "Create Status",
label = createStatus
)
}
}
}
}
@DayNightPreviews
@Composable
fun InternalLocalBackupScreenPreview() {
Previews.Preview {
InternalLocalBackupScreen(
backupsEnabled = true,
selectedDirectory = "/storage/emulated/0/Signal/Backups",
lastBackupTimeString = "1 hour ago",
callback = Callback.Empty
)
}
}

View File

@@ -84,6 +84,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
import org.thoughtcrime.securesms.util.Util
class InternalBackupPlaygroundFragment : ComposeFragment() {
@@ -192,7 +193,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
savePlaintextCopyLauncher.launch(intent)
},
onExportNewStyleLocalBackupClicked = { LocalBackupJob.enqueueArchive() },
onExportNewStyleLocalBackupClicked = { LocalBackupJob.enqueueArchive(false) },
onWipeDataAndRestoreFromRemoteClicked = {
MaterialAlertDialogBuilder(context)
.setTitle("Are you sure?")
@@ -229,7 +230,9 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
MaterialAlertDialogBuilder(context)
.setTitle("Are you sure?")
.setMessage("After you choose a file to import, this will delete all of your chats, then restore them from the file! Only do this on a test device!")
.setPositiveButton("Wipe and restore") { _, _ -> viewModel.import(SignalStore.settings.signalBackupDirectory!!) }
.setPositiveButton("Wipe and restore") { _, _ ->
startActivity(InternalNewLocalRestoreActivity.getIntent(context, finish = false))
}
.show()
},
onDeleteRemoteBackup = {

View File

@@ -5,7 +5,6 @@
package org.thoughtcrime.securesms.components.settings.app.internal.backup
import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
@@ -43,11 +42,6 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.DebugBackupMetadata
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.ArchiveResult
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver.FailureCause
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader.Companion.MAC_SIZE
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -55,7 +49,6 @@ import org.thoughtcrime.securesms.database.AttachmentTable.DebugAttachmentStats
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
@@ -193,29 +186,6 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
fun import(uri: Uri) {
_state.value = _state.value.copy(statusMessage = "Importing new-style local backup...")
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable {
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, uri)!!
val snapshotInfo = archiveFileSystem.listSnapshots().firstOrNull() ?: return@fromCallable ArchiveResult.failure(FailureCause.MAIN_STREAM)
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
LocalArchiver.import(snapshotFileSystem, selfData)
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
_state.value = _state.value.copy(statusMessage = "New-style local backup import complete!")
}
}
fun haltAllJobs() {
ArchiveUploadProgress.cancel()

View File

@@ -0,0 +1,126 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import android.content.Context
import android.database.Cursor
import org.signal.core.util.delete
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.readToSingleLongOrNull
import org.signal.core.util.requireBlob
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.attachments.AttachmentMetadata
import org.thoughtcrime.securesms.attachments.LocalBackupKey
import org.thoughtcrime.securesms.util.Util
/**
* Metadata for various attachments. There is a many-to-one relationship with the Attachment table as this metadata
* represents data about a specific data file (plaintext hash).
*/
class AttachmentMetadataTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTable(context, databaseHelper) {
companion object {
private val TAG = Log.tag(AttachmentMetadataTable::class)
const val TABLE_NAME = "attachment_metadata"
const val ID = "_id"
const val PLAINTEXT_HASH = "plaintext_hash"
const val LOCAL_BACKUP_KEY = "local_backup_key"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY,
$PLAINTEXT_HASH TEXT NOT NULL,
$LOCAL_BACKUP_KEY BLOB DEFAULT NULL,
UNIQUE ($PLAINTEXT_HASH)
)
"""
val PROJECTION = arrayOf(LOCAL_BACKUP_KEY)
/**
* Attempts to load metadata from the cursor if present. Returns null iff the cursor contained no
* metadata columns (i.e., no join in the original query). If there are columns, but they are null, the contents of the
* returned [AttachmentMetadata] will be null.
*/
fun getMetadata(cursor: Cursor, localBackupKeyColumn: String = LOCAL_BACKUP_KEY): AttachmentMetadata? {
if (cursor.getColumnIndex(localBackupKeyColumn) >= 0) {
val localBackupKey = cursor.requireBlob(localBackupKeyColumn)?.let { LocalBackupKey(it) }
return AttachmentMetadata(localBackupKey)
}
return null
}
}
fun insert(plaintextHash: String, localBackupKey: ByteArray): Long {
val rowId = writableDatabase
.insertInto(TABLE_NAME)
.values(PLAINTEXT_HASH to plaintextHash, LOCAL_BACKUP_KEY to localBackupKey)
.run(conflictStrategy = SQLiteDatabase.CONFLICT_IGNORE)
if (rowId > 0) {
return rowId
}
return readableDatabase
.select(ID)
.from(TABLE_NAME)
.where("$PLAINTEXT_HASH = ?", plaintextHash)
.run()
.readToSingleLongOrNull()!!
}
fun cleanup() {
writableDatabase
.delete(TABLE_NAME)
.where("$ID NOT IN (SELECT DISTINCT ${AttachmentTable.METADATA_ID} FROM ${AttachmentTable.TABLE_NAME})")
.run()
}
fun insertNewKeysForExistingAttachments() {
writableDatabase.withinTransaction {
do {
val hashes: List<String> = readableDatabase
.select("DISTINCT ${AttachmentTable.DATA_HASH_END}")
.from(AttachmentTable.TABLE_NAME)
.where("${AttachmentTable.DATA_HASH_END} IS NOT NULL AND ${AttachmentTable.DATA_FILE} IS NOT NULL AND ${AttachmentTable.METADATA_ID} IS NULL")
.limit(1000)
.run()
.readToList { it.requireNonNullString(AttachmentTable.DATA_HASH_END) }
if (hashes.isNotEmpty()) {
val newKeys: List<Pair<String, ByteArray>> = hashes.zip(hashes.map { Util.getSecretBytes(64) })
newKeys.forEach { (hash, key) ->
var rowId = writableDatabase
.insertInto(TABLE_NAME)
.values(PLAINTEXT_HASH to hash, LOCAL_BACKUP_KEY to key)
.run(conflictStrategy = SQLiteDatabase.CONFLICT_IGNORE)
if (rowId == -1L) {
rowId = readableDatabase
.select(ID)
.from(TABLE_NAME)
.where("$PLAINTEXT_HASH = ?", hash)
.run()
.readToSingleLongOrNull()!!
}
writableDatabase
.update(AttachmentTable.TABLE_NAME)
.values(AttachmentTable.METADATA_ID to rowId)
.where("${AttachmentTable.DATA_HASH_END} = ?", hash)
.run()
}
}
} while (hashes.isNotEmpty())
}
}
}

View File

@@ -60,6 +60,7 @@ import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireIntOrNull
import org.signal.core.util.requireLong
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireNonNullBlob
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireObject
@@ -73,6 +74,7 @@ import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.LocalBackupKey
import org.thoughtcrime.securesms.attachments.LocalStickerAttachment
import org.thoughtcrime.securesms.attachments.WallpaperAttachment
import org.thoughtcrime.securesms.audio.AudioHash
@@ -191,6 +193,7 @@ class AttachmentTable(
const val ATTACHMENT_UUID = "attachment_uuid"
const val OFFLOAD_RESTORED_AT = "offload_restored_at"
const val QUOTE_TARGET_CONTENT_TYPE = "quote_target_content_type"
const val METADATA_ID = "metadata_id"
const val ATTACHMENT_JSON_ALIAS = "attachment_json"
@@ -249,6 +252,8 @@ class AttachmentTable(
ATTACHMENT_UUID
)
private val PROJECTION_WITH_METADATA = PROJECTION.map { if (it == ID) "$TABLE_NAME.$ID" else it }.toTypedArray() + AttachmentMetadataTable.PROJECTION
@JvmField
val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
@@ -293,7 +298,8 @@ class AttachmentTable(
$ATTACHMENT_UUID TEXT DEFAULT NULL,
$OFFLOAD_RESTORED_AT INTEGER DEFAULT 0,
$QUOTE_TARGET_CONTENT_TYPE TEXT DEFAULT NULL,
$ARCHIVE_THUMBNAIL_TRANSFER_STATE INTEGER DEFAULT ${ArchiveTransferState.NONE.value}
$ARCHIVE_THUMBNAIL_TRANSFER_STATE INTEGER DEFAULT ${ArchiveTransferState.NONE.value},
$METADATA_ID INTEGER DEFAULT NULL REFERENCES ${AttachmentMetadataTable.TABLE_NAME} (${AttachmentMetadataTable.ID})
)
"""
@@ -309,11 +315,12 @@ class AttachmentTable(
"CREATE INDEX IF NOT EXISTS $DATA_HASH_REMOTE_KEY_INDEX ON $TABLE_NAME ($DATA_HASH_END, $REMOTE_KEY);",
"CREATE INDEX IF NOT EXISTS $DATA_FILE_INDEX ON $TABLE_NAME ($DATA_FILE);",
"CREATE INDEX IF NOT EXISTS attachment_archive_transfer_state ON $TABLE_NAME ($ARCHIVE_TRANSFER_STATE);",
"CREATE INDEX IF NOT EXISTS attachment_remote_digest_index ON $TABLE_NAME ($REMOTE_DIGEST);"
"CREATE INDEX IF NOT EXISTS attachment_remote_digest_index ON $TABLE_NAME ($REMOTE_DIGEST);",
"CREATE INDEX IF NOT EXISTS attachment_metadata_id ON $TABLE_NAME ($METADATA_ID);"
)
private val DATA_FILE_INFO_PROJECTION = arrayOf(
ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP, ARCHIVE_CDN, ARCHIVE_TRANSFER_STATE, THUMBNAIL_FILE, THUMBNAIL_RESTORE_STATE, THUMBNAIL_RANDOM
ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP, ARCHIVE_CDN, ARCHIVE_TRANSFER_STATE, THUMBNAIL_FILE, THUMBNAIL_RESTORE_STATE, THUMBNAIL_RANDOM, METADATA_ID
)
private const val QUOTE_THUMBNAIL_DIMEN = 200
@@ -325,6 +332,8 @@ class AttachmentTable(
/** Indicates a quote from a free-tier backup restore is pending potential reconstruction from a parent attachment. */
const val QUOTE_PENDING_RECONSTRUCTION = 3
private val TABLE_NAME_WITH_METADTA = "$TABLE_NAME LEFT JOIN ${AttachmentMetadataTable.TABLE_NAME} ON $TABLE_NAME.$METADATA_ID = ${AttachmentMetadataTable.TABLE_NAME}.${AttachmentMetadataTable.ID}"
@JvmStatic
@Throws(IOException::class)
fun newDataFile(context: Context): File {
@@ -472,13 +481,15 @@ class AttachmentTable(
.firstOrNull()
}
fun getAttachmentIdByPlaintextHashAndRemoteKey(plaintextHash: ByteArray, remoteKey: ByteArray): AttachmentId? {
fun getAttachmentWithMetadata(attachmentId: AttachmentId): DatabaseAttachment? {
return readableDatabase
.select(ID)
.from("$TABLE_NAME INDEXED BY $DATA_HASH_REMOTE_KEY_INDEX")
.where("$DATA_HASH_END = ? AND $REMOTE_KEY = ?", Base64.encodeWithPadding(plaintextHash), Base64.encodeWithPadding(remoteKey))
.select(*PROJECTION_WITH_METADATA)
.from(TABLE_NAME_WITH_METADTA)
.where("$TABLE_NAME.$ID = ?", attachmentId.id)
.run()
.readToSingleObject { AttachmentId(it.requireLong(ID)) }
.readToList { it.readAttachments() }
.flatten()
.firstOrNull()
}
fun getAttachmentsForMessage(mmsId: Long): List<DatabaseAttachment> {
@@ -492,23 +503,37 @@ class AttachmentTable(
.flatten()
}
@JvmOverloads
fun getAttachmentsForMessages(mmsIds: Collection<Long?>, excludeTranscodingQuotes: Boolean = false): Map<Long, List<DatabaseAttachment>> {
fun getAttachmentsForMessagesArchive(mmsIds: Collection<Long>): Map<Long, List<DatabaseAttachment>> {
if (mmsIds.isEmpty()) {
return emptyMap()
}
val query = SqlUtil.buildFastCollectionQuery(MESSAGE_ID, mmsIds)
val where = if (excludeTranscodingQuotes) {
"(${query.where}) AND $QUOTE != $QUOTE_PENDING_TRANSCODE"
} else {
query.where
val where = "(${query.where}) AND $QUOTE != $QUOTE_PENDING_TRANSCODE"
return readableDatabase
.select(*PROJECTION_WITH_METADATA)
.from(TABLE_NAME_WITH_METADTA)
.where(where, query.whereArgs)
.orderBy("$TABLE_NAME.$ID ASC")
.run()
.groupBy { cursor ->
val attachment = cursor.readAttachment()
attachment.mmsId to attachment
}
}
fun getAttachmentsForMessages(mmsIds: Collection<Long?>): Map<Long, List<DatabaseAttachment>> {
if (mmsIds.isEmpty()) {
return emptyMap()
}
val query = SqlUtil.buildFastCollectionQuery(MESSAGE_ID, mmsIds)
return readableDatabase
.select(*PROJECTION)
.from(TABLE_NAME)
.where(where, query.whereArgs)
.where(query.where, query.whereArgs)
.orderBy("$ID ASC")
.run()
.groupBy { cursor ->
@@ -559,38 +584,20 @@ class AttachmentTable(
.flatten()
}
fun getLocalArchivableAttachment(plaintextHash: String, remoteKey: String): LocalArchivableAttachment? {
return readableDatabase
.select(*PROJECTION)
.from(TABLE_NAME)
.where("$DATA_HASH_END = ? AND $REMOTE_KEY = ?")
.orderBy("$ID DESC")
.limit(1)
.run()
.readToSingleObject {
LocalArchivableAttachment(
file = File(it.requireNonNullString(DATA_FILE)),
random = it.requireNonNullBlob(DATA_RANDOM),
size = it.requireLong(DATA_SIZE),
remoteKey = Base64.decode(it.requireNonNullString(REMOTE_KEY)),
plaintextHash = Base64.decode(it.requireNonNullString(DATA_HASH_END))
)
}
}
fun getLocalArchivableAttachments(): List<LocalArchivableAttachment> {
return readableDatabase
.select(*PROJECTION)
.from(TABLE_NAME)
.where("$REMOTE_KEY IS NOT NULL AND $DATA_HASH_END IS NOT NULL AND $DATA_FILE IS NOT NULL")
.orderBy("$ID DESC")
.select(*PROJECTION_WITH_METADATA)
.from(TABLE_NAME_WITH_METADTA)
.where("$DATA_HASH_END IS NOT NULL AND $DATA_FILE IS NOT NULL AND ${AttachmentMetadataTable.TABLE_NAME}.${AttachmentMetadataTable.LOCAL_BACKUP_KEY} IS NOT NULL")
.orderBy("$TABLE_NAME.$ID DESC")
.run()
.readToList {
LocalArchivableAttachment(
attachmentId = AttachmentId(it.requireLong(ID)),
file = File(it.requireNonNullString(DATA_FILE)),
random = it.requireNonNullBlob(DATA_RANDOM),
size = it.requireLong(DATA_SIZE),
remoteKey = Base64.decode(it.requireNonNullString(REMOTE_KEY)),
localBackupKey = AttachmentMetadataTable.getMetadata(it)!!.localBackupKey!!,
plaintextHash = Base64.decode(it.requireNonNullString(DATA_HASH_END))
)
}
@@ -646,26 +653,6 @@ class AttachmentTable(
}
}
fun getRestorableAttachments(batchSize: Int): List<RestorableAttachment> {
return readableDatabase
.select(ID, MESSAGE_ID, DATA_SIZE, DATA_HASH_END, REMOTE_KEY, STICKER_PACK_ID)
.from(TABLE_NAME)
.where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE)
.limit(batchSize)
.orderBy("$ID DESC")
.run()
.readToList {
RestorableAttachment(
attachmentId = AttachmentId(it.requireLong(ID)),
mmsId = it.requireLong(MESSAGE_ID),
size = it.requireLong(DATA_SIZE),
plaintextHash = it.requireString(DATA_HASH_END)?.let { hash -> Base64.decode(hash) },
remoteKey = it.requireString(REMOTE_KEY)?.let { key -> Base64.decode(key) },
stickerPackId = it.requireString(STICKER_PACK_ID)
)
}
}
fun getRestorableOptimizedAttachments(): List<RestorableAttachment> {
return readableDatabase
.select(ID, MESSAGE_ID, DATA_SIZE, DATA_HASH_END, REMOTE_KEY, STICKER_PACK_ID)
@@ -685,6 +672,25 @@ class AttachmentTable(
}
}
fun getRestorableLocalAttachments(batchSize: Int): List<LocalRestorableAttachment> {
return readableDatabase
.select("$TABLE_NAME.$ID", MESSAGE_ID, DATA_HASH_END, STICKER_PACK_ID, AttachmentMetadataTable.LOCAL_BACKUP_KEY)
.from(TABLE_NAME_WITH_METADTA)
.where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE)
.limit(batchSize)
.orderBy("$TABLE_NAME.$ID DESC")
.run()
.readToList {
LocalRestorableAttachment(
attachmentId = AttachmentId(it.requireLong(ID)),
mmsId = it.requireLong(MESSAGE_ID),
plaintextHash = it.requireString(DATA_HASH_END)?.let { hash -> Base64.decode(hash) },
localBackupKey = it.requireBlob(AttachmentMetadataTable.LOCAL_BACKUP_KEY)?.let { key -> LocalBackupKey(key) },
stickerPackId = it.requireString(STICKER_PACK_ID)
)
}
}
fun getRemainingRestorableAttachmentSize(): Long {
return readableDatabase
.rawQuery(
@@ -1239,6 +1245,8 @@ class AttachmentTable(
.where("$MESSAGE_ID = ?", mmsId)
.run()
SignalDatabase.attachmentMetadata.cleanup()
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
deleteCount
@@ -1315,11 +1323,14 @@ class AttachmentTable(
TRANSFER_STATE to TRANSFER_PROGRESS_DONE,
ARCHIVE_TRANSFER_STATE to ArchiveTransferState.NONE.value,
BLUR_HASH to null,
CONTENT_TYPE to MediaUtil.VIEW_ONCE
CONTENT_TYPE to MediaUtil.VIEW_ONCE,
METADATA_ID to null
)
.where("$MESSAGE_ID = ?", messageId)
.run()
SignalDatabase.attachmentMetadata.cleanup()
AppDependencies.databaseObserver.notifyAttachmentDeletedObservers()
val threadId = messages.getThreadIdForMessage(messageId)
@@ -1357,6 +1368,8 @@ class AttachmentTable(
.where("$ID = ?", id.id)
.run()
SignalDatabase.attachmentMetadata.cleanup()
if (filePath != null && isSafeToDeleteDataFile(filePath, id)) {
filePathsToDelete += filePath
contentType?.let { contentTypesToDelete += it }
@@ -1490,6 +1503,7 @@ class AttachmentTable(
Log.d(TAG, "[deleteAllAttachments]")
writableDatabase.deleteAll(TABLE_NAME)
SignalDatabase.attachmentMetadata.cleanup()
FileUtils.deleteDirectoryContents(context.getDir(DIRECTORY, Context.MODE_PRIVATE))
@@ -1647,6 +1661,7 @@ class AttachmentTable(
values.put(DATA_RANDOM, hashMatch.random)
values.put(DATA_HASH_START, hashMatch.hashEnd)
values.put(DATA_HASH_END, hashMatch.hashEnd)
values.put(METADATA_ID, hashMatch.metadataId)
if (archiveRestore) {
// We aren't getting an updated remote key/mediaName when restoring, can reuse
values.put(ARCHIVE_CDN, hashMatch.archiveCdn)
@@ -2295,7 +2310,8 @@ class AttachmentTable(
archiveCdn = if (jsonObject.isNull(ARCHIVE_CDN)) null else jsonObject.getInt(ARCHIVE_CDN),
thumbnailRestoreState = ThumbnailRestoreState.deserialize(jsonObject.getInt(THUMBNAIL_RESTORE_STATE)),
archiveTransferState = ArchiveTransferState.deserialize(jsonObject.getInt(ARCHIVE_TRANSFER_STATE)),
uuid = UuidUtil.parseOrNull(jsonObject.getString(ATTACHMENT_UUID))
uuid = UuidUtil.parseOrNull(jsonObject.getString(ATTACHMENT_UUID)),
metadata = null
)
}
}
@@ -2764,6 +2780,12 @@ class AttachmentTable(
val attachmentId: AttachmentId = writableDatabase.withinTransaction { db ->
val plaintextHash = attachment.plaintextHash.takeIf { it.isNotEmpty() }?.let { Base64.encodeWithPadding(it) }
val metadataId: Long? = if (plaintextHash != null && attachment.localBackupKey != null) {
SignalDatabase.attachmentMetadata.insert(plaintextHash, attachment.localBackupKey)
} else {
null
}
val contentValues = ContentValues().apply {
put(MESSAGE_ID, messageId)
put(CONTENT_TYPE, attachment.contentType)
@@ -2808,6 +2830,10 @@ class AttachmentTable(
} else {
putNull(REMOTE_INCREMENTAL_DIGEST)
}
if (metadataId != null && metadataId > 0) {
put(METADATA_ID, metadataId)
}
}
val rowId = db.insert(TABLE_NAME, null, contentValues)
@@ -2870,6 +2896,7 @@ class AttachmentTable(
put(THUMBNAIL_RANDOM, dataFileInfo.thumbnailRandom)
put(THUMBNAIL_FILE, dataFileInfo.thumbnailFile)
put(ATTACHMENT_UUID, stickerAttachment.uuid?.toString())
put(METADATA_ID, dataFileInfo.metadataId)
}
val rowId = db.insert(TABLE_NAME, null, contentValues)
@@ -2911,7 +2938,7 @@ class AttachmentTable(
attachmentId = AttachmentId(rowId)
}
return attachmentId as AttachmentId
return attachmentId
}
/**
@@ -2994,6 +3021,7 @@ class AttachmentTable(
contentValues.put(DATA_RANDOM, hashMatch.random)
contentValues.put(DATA_HASH_START, fileWriteResult.hash)
contentValues.put(DATA_HASH_END, hashMatch.hashEnd)
contentValues.put(METADATA_ID, hashMatch.metadataId)
if (hashMatch.transformProperties.skipTransform) {
Log.i(TAG, "[insertAttachmentWithData] The hash match has a DATA_HASH_END and skipTransform=true, so skipping transform of the new file as well. (MessageId: $messageId, ${attachment.uri})")
@@ -3249,7 +3277,8 @@ class AttachmentTable(
archiveCdn = cursor.requireIntOrNull(ARCHIVE_CDN),
thumbnailRestoreState = ThumbnailRestoreState.deserialize(cursor.requireInt(THUMBNAIL_RESTORE_STATE)),
archiveTransferState = ArchiveTransferState.deserialize(cursor.requireInt(ARCHIVE_TRANSFER_STATE)),
uuid = UuidUtil.parseOrNull(cursor.requireString(ATTACHMENT_UUID))
uuid = UuidUtil.parseOrNull(cursor.requireString(ATTACHMENT_UUID)),
metadata = AttachmentMetadataTable.getMetadata(cursor)
)
}
@@ -3278,7 +3307,8 @@ class AttachmentTable(
archiveTransferState = this.requireInt(ARCHIVE_TRANSFER_STATE),
thumbnailFile = this.requireString(THUMBNAIL_FILE),
thumbnailRandom = this.requireBlob(THUMBNAIL_RANDOM),
thumbnailRestoreState = this.requireInt(THUMBNAIL_RESTORE_STATE)
thumbnailRestoreState = this.requireInt(THUMBNAIL_RESTORE_STATE),
metadataId = this.requireLongOrNull(METADATA_ID)
)
}
@@ -3649,7 +3679,8 @@ class AttachmentTable(
val archiveTransferState: Int,
val thumbnailFile: String?,
val thumbnailRandom: ByteArray?,
val thumbnailRestoreState: Int
val thumbnailRestoreState: Int,
val metadataId: Long?
)
@VisibleForTesting
@@ -3840,11 +3871,12 @@ class AttachmentTable(
class SyncAttachment(val id: AttachmentId, val uuid: UUID?, val digest: ByteArray?, val plaintextHash: String?)
class LocalArchivableAttachment(
val attachmentId: AttachmentId,
val file: File,
val random: ByteArray,
val size: Long,
val plaintextHash: ByteArray,
val remoteKey: ByteArray
val localBackupKey: LocalBackupKey
)
data class RestorableAttachment(
@@ -3864,6 +3896,22 @@ class AttachmentTable(
}
}
data class LocalRestorableAttachment(
val attachmentId: AttachmentId,
val mmsId: Long,
val plaintextHash: ByteArray?,
val localBackupKey: LocalBackupKey?,
val stickerPackId: String?
) {
override fun equals(other: Any?): Boolean {
return this === other || attachmentId == (other as? RestorableAttachment)?.attachmentId
}
override fun hashCode(): Int {
return attachmentId.hashCode()
}
}
data class DebugAttachmentStats(
val totalAttachmentRows: Long = 0L,
val totalUniqueMediaNamesEligibleForUpload: Long = 0L,

View File

@@ -82,6 +82,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val backupMediaSnapshotTable: BackupMediaSnapshotTable = BackupMediaSnapshotTable(context, this)
val pollTable: PollTables = PollTables(context, this)
val lastResortKeyTuples: LastResortKeyTupleTable = LastResortKeyTupleTable(context, this)
val attachmentMetadataTable: AttachmentMetadataTable = AttachmentMetadataTable(context, this)
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true)
@@ -152,6 +153,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
executeStatements(db, PollTables.CREATE_TABLE)
db.execSQL(BackupMediaSnapshotTable.CREATE_TABLE)
db.execSQL(LastResortKeyTupleTable.CREATE_TABLE)
db.execSQL(AttachmentMetadataTable.CREATE_TABLE)
executeStatements(db, RecipientTable.CREATE_INDEXS)
executeStatements(db, MessageTable.CREATE_INDEXS)
@@ -597,5 +599,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
@get:JvmName("lastResortKeyTuples")
val lastResortKeyTuples: LastResortKeyTupleTable
get() = instance!!.lastResortKeyTuples
@get:JvmStatic
@get:JvmName("attachmentMetadata")
val attachmentMetadata: AttachmentMetadataTable
get() = instance!!.attachmentMetadataTable
}
}

View File

@@ -152,6 +152,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V295_AddLastRestore
import org.thoughtcrime.securesms.database.helpers.migration.V296_RemovePollVoteConstraint
import org.thoughtcrime.securesms.database.helpers.migration.V297_AddPinnedMessageColumns
import org.thoughtcrime.securesms.database.helpers.migration.V298_DoNotBackupReleaseNotes
import org.thoughtcrime.securesms.database.helpers.migration.V299_AddAttachmentMetadataTable
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -310,10 +311,11 @@ object SignalDatabaseMigrations {
295 to V295_AddLastRestoreKeyTypeTableIfMissingMigration,
296 to V296_RemovePollVoteConstraint,
297 to V297_AddPinnedMessageColumns,
298 to V298_DoNotBackupReleaseNotes
298 to V298_DoNotBackupReleaseNotes,
299 to V299_AddAttachmentMetadataTable
)
const val DATABASE_VERSION = 298
const val DATABASE_VERSION = 299
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* We need to keep track of the local backup key
*/
@Suppress("ClassName")
object V299_AddAttachmentMetadataTable : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL(
"""
CREATE TABLE attachment_metadata (
_id INTEGER PRIMARY KEY,
plaintext_hash TEXT NOT NULL,
local_backup_key BLOB DEFAULT NULL,
UNIQUE (plaintext_hash)
)
"""
)
db.execSQL("ALTER TABLE attachment ADD COLUMN metadata_id INTEGER DEFAULT NULL REFERENCES attachment_metadata (_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS attachment_metadata_id ON attachment (metadata_id)")
}
}

View File

@@ -1,9 +1,9 @@
package org.thoughtcrime.securesms.jobs
import android.net.Uri
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.Result
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
@@ -13,13 +13,12 @@ import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.service.GenericForegroundService
import org.thoughtcrime.securesms.service.NotificationController
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.StorageUtil
import java.io.IOException
/**
@@ -69,17 +68,11 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
val stopwatch = Stopwatch("archive-export")
val archiveFileSystem = if (BackupUtil.isUserSelectionRequired(context)) {
val backupDirectoryUri = SignalStore.settings.signalBackupDirectory
if (backupDirectoryUri == null || backupDirectoryUri.path == null) {
throw IOException("Backup Directory has not been selected!")
}
ArchiveFileSystem.fromUri(context, backupDirectoryUri)
} else {
ArchiveFileSystem.fromFile(context, StorageUtil.getOrCreateBackupV2Directory())
val backupDirectoryUri = SignalStore.backup.newLocalBackupsDirectory?.let { Uri.parse(it) }
if (backupDirectoryUri == null || backupDirectoryUri.path == null) {
throw IOException("Backup Directory has not been selected!")
}
val archiveFileSystem = ArchiveFileSystem.fromUri(context, backupDirectoryUri)
if (archiveFileSystem == null) {
BackupFileIOError.ACCESS_ERROR.postNotification(context)
@@ -95,6 +88,8 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
stopwatch.split("create-snapshot")
try {
SignalDatabase.attachmentMetadata.insertNewKeysForExistingAttachments()
try {
val result = LocalArchiver.export(snapshotFileSystem, archiveFileSystem.filesFileSystem, stopwatch, cancellationSignal = { isCanceled })
Log.i(TAG, "Archive finished with result: $result")
@@ -108,22 +103,10 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
stopwatch.split("archive-create")
// todo [local-backup] verify local backup
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.PROGRESS_VERIFYING))
val valid = true
stopwatch.split("archive-verify")
if (valid) {
snapshotFileSystem.finalize()
stopwatch.split("archive-finalize")
} else {
BackupFileIOError.VERIFICATION_FAILED.postNotification(context)
}
snapshotFileSystem.finalize()
stopwatch.split("archive-finalize")
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.FINISHED))
stopwatch.stop(TAG)
} catch (e: BackupCanceledException) {
EventBus.getDefault().post(LocalBackupV2Event(LocalBackupV2Event.Type.FINISHED))
Log.w(TAG, "Archive cancelled")
@@ -148,6 +131,8 @@ class LocalArchiveJob internal constructor(parameters: Parameters) : Job(paramet
stopwatch.split("delete-unused")
stopwatch.stop(TAG)
SignalStore.backup.newLocalBackupsLastBackupTime = System.currentTimeMillis()
} finally {
notification?.close()
EventBus.getDefault().unregister(updater)

View File

@@ -36,6 +36,7 @@ import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
public final class LocalBackupJob extends BaseJob {
@@ -65,11 +66,12 @@ public final class LocalBackupJob extends BaseJob {
}
}
public static void enqueueArchive() {
public static void enqueueArchive(boolean delay) {
JobManager jobManager = AppDependencies.getJobManager();
Parameters.Builder parameters = new Parameters.Builder()
.setQueue(QUEUE)
.setMaxInstancesForFactory(1)
.setInitialDelay(delay ? TimeUnit.MINUTES.toMillis(30) : 0)
.setMaxAttempts(3);
jobManager.add(new LocalArchiveJob(parameters.build()));

View File

@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.RestorableAttachment
import org.thoughtcrime.securesms.database.AttachmentTable.LocalRestorableAttachment
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@@ -47,15 +47,14 @@ class RestoreLocalAttachmentJob private constructor(
val jobManager = AppDependencies.jobManager
do {
val possibleRestorableAttachments: List<RestorableAttachment> = SignalDatabase.attachments.getRestorableAttachments(500)
val possibleRestorableAttachments: List<LocalRestorableAttachment> = SignalDatabase.attachments.getRestorableLocalAttachments(500)
val notRestorableAttachments = ArrayList<AttachmentId>(possibleRestorableAttachments.size)
val restoreAttachmentJobs: MutableList<Job> = ArrayList(possibleRestorableAttachments.size)
possibleRestorableAttachments
.forEachIndexed { index, attachment ->
val fileInfo = if (attachment.plaintextHash != null && attachment.remoteKey != null) {
val mediaName = MediaName.fromPlaintextHashAndRemoteKey(attachment.plaintextHash, attachment.remoteKey).name
mediaNameToFileInfo[mediaName]
val fileInfo = if (attachment.plaintextHash != null && attachment.localBackupKey != null) {
mediaNameToFileInfo[MediaName.forLocalBackupFilename(attachment.plaintextHash, attachment.localBackupKey.key).name]
} else {
null
}
@@ -90,7 +89,7 @@ class RestoreLocalAttachmentJob private constructor(
}
}
private constructor(queue: String, attachment: RestorableAttachment, info: DocumentFileInfo) : this(
private constructor(queue: String, attachment: LocalRestorableAttachment, info: DocumentFileInfo) : this(
Parameters.Builder()
.setQueue(queue)
.setLifespan(Parameters.IMMORTAL)
@@ -122,15 +121,15 @@ class RestoreLocalAttachmentJob private constructor(
override fun run(): Result {
Log.i(TAG, "onRun() messageId: $messageId attachmentId: $attachmentId")
val attachment = SignalDatabase.attachments.getAttachment(attachmentId)
val attachment = SignalDatabase.attachments.getAttachmentWithMetadata(attachmentId)
if (attachment == null) {
Log.w(TAG, "attachment no longer exists.")
return Result.failure()
}
if (attachment.remoteDigest == null || attachment.remoteKey == null) {
Log.w(TAG, "Attachment no longer has a remote digest or key")
if (attachment.dataHash == null || attachment.metadata?.localBackupKey == null) {
Log.w(TAG, "Attachment no longer has a plaintext hash or local backup key")
return Result.failure()
}
@@ -144,7 +143,6 @@ class RestoreLocalAttachmentJob private constructor(
return Result.success()
}
val combinedKey = Base64.decode(attachment.remoteKey)
val streamSupplier = StreamSupplier { ArchiveFileSystem.openInputStream(context, restoreUri) ?: throw IOException("Unable to open stream") }
try {
@@ -154,10 +152,9 @@ class RestoreLocalAttachmentJob private constructor(
streamSupplier = streamSupplier,
streamLength = size,
plaintextLength = attachment.size,
combinedKeyMaterial = combinedKey,
integrityCheck = IntegrityCheck.forEncryptedDigestAndPlaintextHash(
encryptedDigest = attachment.remoteDigest,
plaintextHash = attachment.dataHash
combinedKeyMaterial = attachment.metadata.localBackupKey.key,
integrityCheck = IntegrityCheck.forPlaintextHash(
plaintextHash = Base64.decode(attachment.dataHash)
),
incrementalDigest = null,
incrementalMacChunkSize = 0

View File

@@ -99,6 +99,10 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
private const val KEY_MESSAGE_CUTOFF_DURATION = "backup.message_cutoff_duration"
private const val KEY_LAST_USED_MESSAGE_CUTOFF_TIME = "backup.last_used_message_cutoff_time"
private const val KEY_NEW_LOCAL_BACKUPS_ENABLED = "backup.new_local_backups_enabled"
private const val KEY_NEW_LOCAL_BACKUPS_DIRECTORY = "backup.new_local_backups_directory"
private const val KEY_NEW_LOCAL_BACKUPS_LAST_BACKUP_TIME = "backup.new_local_backups_last_backup_time"
private val cachedCdnCredentialsExpiresIn: Duration = 12.hours
private val lock = ReentrantLock()
@@ -441,6 +445,27 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
*/
var lastUsedMessageCutoffTime: Long by longValue(KEY_LAST_USED_MESSAGE_CUTOFF_TIME, 0)
/**
* True if the new local backup system is enabled, otherwise false.
*/
private val newLocalBackupsEnabledValue = booleanValue(KEY_NEW_LOCAL_BACKUPS_ENABLED, false)
var newLocalBackupsEnabled: Boolean by newLocalBackupsEnabledValue
val newLocalBackupsEnabledFlow: Flow<Boolean> by lazy { newLocalBackupsEnabledValue.toFlow() }
/**
* The directory URI path selected for new local backups.
*/
private val newLocalBackupsDirectoryValue = stringValue(KEY_NEW_LOCAL_BACKUPS_DIRECTORY, null as String?)
var newLocalBackupsDirectory: String? by newLocalBackupsDirectoryValue
val newLocalBackupsDirectoryFlow: Flow<String?> by lazy { newLocalBackupsDirectoryValue.toFlow() }
/**
* The timestamp of the last successful new local backup.
*/
private val newLocalBackupsLastBackupTimeValue = longValue(KEY_NEW_LOCAL_BACKUPS_LAST_BACKUP_TIME, -1)
var newLocalBackupsLastBackupTime: Long by newLocalBackupsLastBackupTimeValue
val newLocalBackupsLastBackupTimeFlow: Flow<Long> by lazy { newLocalBackupsLastBackupTimeValue.toFlow() }
/**
* When we are told by the server that we are out of storage space, we should show
* UX treatment to make the user aware of this.

View File

@@ -32,6 +32,15 @@ val RestoreDecisionState.isWantingManualRemoteRestore: Boolean
else -> false
}
/** Has the user indicated they want a manual local v2 restore but not via quick restore. */
val RestoreDecisionState.isWantingNewLocalBackupRestore: Boolean
get() = when (this.decisionState) {
RestoreDecisionState.State.INTEND_TO_RESTORE -> {
this.intendToRestoreData?.fromLocalV2 == true && !this.intendToRestoreData.hasOldDevice
}
else -> false
}
val RestoreDecisionState.includeDeviceToDeviceTransfer: Boolean
get() = when (this.decisionState) {
RestoreDecisionState.State.INTEND_TO_RESTORE -> {
@@ -49,10 +58,10 @@ val RestoreDecisionState.Companion.Start: RestoreDecisionState
get() = RestoreDecisionState(RestoreDecisionState.State.START)
/** Helper to create a [RestoreDecisionState.State.INTEND_TO_RESTORE] with appropriate data. */
fun RestoreDecisionState.Companion.intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean?): RestoreDecisionState {
fun RestoreDecisionState.Companion.intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean?, fromLocalV2: Boolean? = null): RestoreDecisionState {
return RestoreDecisionState(
decisionState = RestoreDecisionState.State.INTEND_TO_RESTORE,
intendToRestoreData = RestoreDecisionState.IntendToRestoreData(hasOldDevice = hasOldDevice, fromRemote = fromRemote)
intendToRestoreData = RestoreDecisionState.IntendToRestoreData(hasOldDevice = hasOldDevice, fromRemote = fromRemote, fromLocalV2 = fromLocalV2)
)
}

View File

@@ -987,8 +987,8 @@ class RegistrationViewModel : ViewModel() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Start
}
fun intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean? = null) {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.intendToRestore(hasOldDevice, fromRemote)
fun intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean? = null, fromLocalV2: Boolean? = null) {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.intendToRestore(hasOldDevice, fromRemote, fromLocalV2)
}
fun skipRestore() {

View File

@@ -562,6 +562,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration()
EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey())
EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToInternalNewLocalBackupRestore())
}
}

View File

@@ -12,9 +12,12 @@ enum class EnterPhoneNumberMode {
/** Normal registration start, collect number to verify */
NORMAL,
/** User pre-selected restore/transfer flow, collect number to re-register and restore with */
/** User pre-selected restore/transfer flow, collect number to re-register and restore via remote */
COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE,
/** User pre-selected restore/transfer flow, collect number to re-register and restore via local backup v2 */
COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE,
/** User reversed decision on restore and needs to resume normal re-register but automatically start verify */
RESTART_AFTER_COLLECTION
}

View File

@@ -20,11 +20,13 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.Dialogs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
@@ -55,8 +57,16 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
override fun FragmentContent() {
var showSkipRestoreWarning by remember { mutableStateOf(false) }
val restoreMethods = remember {
if (Environment.IS_NIGHTLY || BuildConfig.DEBUG) {
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1, RestoreMethod.FROM_LOCAL_BACKUP_V2)
} else {
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1)
}
}
SelectRestoreMethodScreen(
restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1),
restoreMethods = restoreMethods,
onRestoreMethodClicked = this::startRestoreMethod,
onSkip = {
showSkipRestoreWarning = true
@@ -92,7 +102,11 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
localBackupRestore.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
}
RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> {
sharedViewModel.clearPreviousRegistrationState()
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = true)
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE))
}
}
}
}

View File

@@ -0,0 +1,312 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.restore.AccountEntropyPoolVerification
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.util.StorageUtil
/**
* Internal only registration screen to collect backup folder and AEP. Actual restore will happen
* post-registration when the app re-routes to [org.thoughtcrime.securesms.restore.RestoreActivity] and then
* [InternalNewLocalRestoreActivity]. Yay implicit navigation!
*/
class InternalNewLocalBackupRestore : ComposeFragment() {
private val TAG = Log.tag(InternalNewLocalBackupRestore::class)
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
chooseBackupLocationLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
handleBackupLocationSelected(result.data!!.data!!)
} else {
Log.w(TAG, "Backup location selection cancelled or failed")
}
}
}
@Composable
override fun FragmentContent() {
val selectedDirectory: String? by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
InternalNewLocalBackupRestoreScreen(
selectedDirectory = selectedDirectory,
callback = CallbackImpl()
)
}
private fun launchBackupDirectoryPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
val currentDirectory = SignalStore.backup.newLocalBackupsDirectory
if (currentDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(currentDirectory))
}
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Launching backup directory picker")
chooseBackupLocationLauncher.launch(intent)
} catch (e: Exception) {
Log.w(TAG, "Failed to launch backup directory picker", e)
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
private fun handleBackupLocationSelected(uri: Uri) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
}
private inner class CallbackImpl : Callback {
override fun onSelectDirectoryClick() {
launchBackupDirectoryPicker()
}
override fun onRestoreClick(backupKey: String) {
sharedViewModel.registerWithBackupKey(
context = requireContext(),
backupKey = backupKey,
e164 = null,
pin = null,
aciIdentityKeyPair = null,
pniIdentityKeyPair = null
)
}
}
}
private interface Callback {
fun onSelectDirectoryClick()
fun onRestoreClick(backupKey: String)
object Empty : Callback {
override fun onSelectDirectoryClick() = Unit
override fun onRestoreClick(backupKey: String) = Unit
}
}
@Composable
private fun InternalNewLocalBackupRestoreScreen(
selectedDirectory: String? = null,
callback: Callback
) {
var backupKey by remember { mutableStateOf("") }
var isBackupKeyValid by remember { mutableStateOf(false) }
var aepValidationError by remember { mutableStateOf<AccountEntropyPoolVerification.AEPValidationError?>(null) }
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
var requestFocus by remember { mutableStateOf(true) }
val autoFillHelper = backupKeyAutoFillHelper { newValue ->
backupKey = newValue
val (valid, error) = AccountEntropyPoolVerification.verifyAEP(
backupKey = newValue,
changed = true,
previousAEPValidationError = aepValidationError
)
isBackupKeyValid = valid
aepValidationError = error
}
RegistrationScreen(
title = "Local Backup V2 Restore",
subtitle = null,
bottomContent = {
Buttons.LargeTonal(
onClick = { callback.onRestoreClick(backupKey) },
enabled = isBackupKeyValid && aepValidationError == null && selectedDirectory != null,
modifier = Modifier.align(Alignment.CenterEnd)
) {
Text(text = "Restore")
}
}
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
DirectorySelectionRow(
selectedDirectory = selectedDirectory,
onClick = callback::onSelectDirectoryClick
)
Spacer(modifier = Modifier.height(24.dp))
TextField(
value = backupKey,
onValueChange = { value ->
val newKey = AccountEntropyPool.removeIllegalCharacters(value).take(AccountEntropyPool.LENGTH + 16).lowercase()
val (valid, error) = AccountEntropyPoolVerification.verifyAEP(
backupKey = newKey,
changed = backupKey != newKey,
previousAEPValidationError = aepValidationError
)
backupKey = newKey
isBackupKeyValid = valid
aepValidationError = error
autoFillHelper.onValueChanged(newKey)
},
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onDone = {
if (isBackupKeyValid && aepValidationError == null && selectedDirectory != null) {
keyboardController?.hide()
callback.onRestoreClick(backupKey)
}
}
),
supportingText = { aepValidationError?.ValidationErrorMessage() },
isError = aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.attachBackupKeyAutoFillHelper(autoFillHelper)
.onGloballyPositioned {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
)
}
}
}
@Composable
private fun DirectorySelectionRow(
selectedDirectory: String?,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
text = "Select Backup Directory",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = selectedDirectory ?: "No directory selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun AccountEntropyPoolVerification.AEPValidationError.ValidationErrorMessage() {
when (this) {
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@DayNightPreviews
@Composable
private fun InternalNewLocalBackupRestoreScreenPreview() {
Previews.Preview {
InternalNewLocalBackupRestoreScreen(
selectedDirectory = "/storage/emulated/0/Signal/Backups",
callback = Callback.Empty
)
}
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.Result
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
/**
* Internal only. On launch, attempt to import the most recent backup located in [SignalStore.backup].newLocalBackupsDirectory.
*/
class InternalNewLocalRestoreActivity : BaseActivity() {
companion object {
fun getIntent(context: Context, finish: Boolean = true): Intent = Intent(context, InternalNewLocalRestoreActivity::class.java).apply { putExtra("finish", finish) }
}
private var restoreStatus by mutableStateOf<String>("Unknown")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch(Dispatchers.IO) {
restoreStatus = "Starting..."
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(SignalStore.backup.newLocalBackupsDirectory!!))!!
val snapshotInfo = archiveFileSystem.listSnapshots().first()
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
val result = LocalArchiver.import(snapshotFileSystem, selfData)
if (result is Result.Success) {
restoreStatus = "Success! Finishing"
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
SignalStore.backup.backupSecretRestoreRequired = false
StorageServiceRestore.restore()
withContext(Dispatchers.Main) {
Toast.makeText(this@InternalNewLocalRestoreActivity, "Local backup restored!", Toast.LENGTH_SHORT).show()
RegistrationUtil.maybeMarkRegistrationComplete()
startActivity(MainActivity.clearTop(this@InternalNewLocalRestoreActivity))
if (intent.getBooleanExtra("finish", false)) {
finishAffinity()
}
}
} else {
restoreStatus = "Backup failed"
Toast.makeText(this@InternalNewLocalRestoreActivity, "Local backup failed", Toast.LENGTH_SHORT).show()
}
}
setContent {
SignalTheme {
Surface {
InternalNewLocalRestoreScreen(
status = restoreStatus
)
}
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(restoreEvent: RestoreV2Event) {
this.restoreStatus = "${restoreEvent.type}: ${restoreEvent.count} / ${restoreEvent.estimatedTotalCount}"
}
}
@Composable
private fun InternalNewLocalRestoreScreen(
status: String = ""
) {
RegistrationScreen(
title = "Internal - Local Restore",
subtitle = null,
bottomContent = { }
) {
Text(
text = status,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp)
)
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
@DayNightPreviews
@Composable
private fun InternalNewLocalRestorePreview() {
Previews.Preview {
InternalNewLocalRestoreScreen(status = "Importing...")
}
}

View File

@@ -24,14 +24,18 @@ import org.signal.core.util.ThreadUtil
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.RestoreDirections
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.isWantingManualRemoteRestore
import org.thoughtcrime.securesms.keyvalue.isWantingNewLocalBackupRestore
import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -79,6 +83,8 @@ class RestoreActivity : BaseActivity() {
if (SignalStore.registration.restoreDecisionState.isWantingManualRemoteRestore) {
Log.i(TAG, "User has no available restore methods but previously wanted a remote restore, navigating immediately.")
startActivity(RemoteRestoreActivity.getIntent(this, isOnlyOption = true))
} else if (SignalStore.registration.restoreDecisionState.isWantingNewLocalBackupRestore && (BuildConfig.DEBUG || Environment.IS_NIGHTLY)) {
startActivity(InternalNewLocalRestoreActivity.getIntent(this))
} else {
Log.i(TAG, "No restore methods available, skipping")
sharedViewModel.skipRestore()

View File

@@ -34,11 +34,15 @@ public class LocalBackupListener extends PersistentAlarmManagerListener {
LocalBackupJob.enqueue(false);
}
if (SignalStore.backup().getNewLocalBackupsEnabled()) {
LocalBackupJob.enqueueArchive(SignalStore.settings().isBackupEnabled());
}
return setNextBackupTimeToIntervalFromNow(context);
}
public static void schedule(Context context) {
if (SignalStore.settings().isBackupEnabled()) {
if (SignalStore.settings().isBackupEnabled() || SignalStore.backup().getNewLocalBackupsEnabled()) {
new LocalBackupListener().onReceive(context, getScheduleIntent());
}
}