diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 4416414bac..31bf07694a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -8,6 +8,10 @@ package org.thoughtcrime.securesms.backup.v2 import org.signal.core.util.EventTimer import org.signal.core.util.logging.Log import org.signal.core.util.withinTransaction +import org.signal.libsignal.messagebackup.MessageBackup +import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult +import org.signal.libsignal.messagebackup.MessageBackupKey +import org.signal.libsignal.protocol.ServiceId.Aci import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore @@ -16,6 +20,7 @@ import org.thoughtcrime.securesms.backup.v2.processor.CallLogBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter @@ -23,6 +28,8 @@ import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.RecipientId import org.whispersystems.signalservice.api.NetworkResult @@ -36,6 +43,7 @@ import kotlin.time.Duration.Companion.milliseconds object BackupRepository { private val TAG = Log.tag(BackupRepository::class.java) + private const val VERSION = 1L fun export(plaintext: Boolean = false): ByteArray { val eventTimer = EventTimer() @@ -52,7 +60,15 @@ object BackupRepository { ) } + val exportState = ExportState() + writer.use { + writer.write( + BackupInfo( + version = VERSION, + backupTimeMs = System.currentTimeMillis() + ) + ) // Note: Without a transaction, we may export inconsistent state. But because we have a transaction, // writes from other threads are blocked. This is something to think more about. SignalDatabase.rawDatabase.withinTransaction { @@ -61,12 +77,12 @@ object BackupRepository { eventTimer.emit("account") } - RecipientBackupProcessor.export { + RecipientBackupProcessor.export(exportState) { writer.write(it) eventTimer.emit("recipient") } - ChatBackupProcessor.export { frame -> + ChatBackupProcessor.export(exportState) { frame -> writer.write(frame) eventTimer.emit("thread") } @@ -76,7 +92,7 @@ object BackupRepository { eventTimer.emit("call") } - ChatItemBackupProcessor.export { frame -> + ChatItemBackupProcessor.export(exportState) { frame -> writer.write(frame) eventTimer.emit("message") } @@ -88,6 +104,13 @@ object BackupRepository { return outputStream.toByteArray() } + fun validate(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData): ValidationResult { + val masterKey = SignalStore.svr().getOrCreateMasterKey() + val key = MessageBackupKey(masterKey.serialize(), Aci.parseFromBinary(selfData.aci.toByteArray())) + + return MessageBackup.validate(key, inputStreamFactory, length) + } + fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) { val eventTimer = EventTimer() @@ -102,6 +125,15 @@ object BackupRepository { ) } + val header = frameReader.getHeader() + if (header == null) { + Log.e(TAG, "Backup is missing header!") + return + } else if (header.version > VERSION) { + Log.e(TAG, "Backup version is newer than we understand: ${header.version}") + return + } + // Note: Without a transaction, bad imports could lead to lost data. But because we have a transaction, // writes from other threads are blocked. This is something to think more about. SignalDatabase.rawDatabase.withinTransaction { @@ -117,6 +149,7 @@ object BackupRepository { SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey) SignalDatabase.recipients.setProfileSharing(selfId, true) + eventTimer.emit("setup") val backupState = BackupState() val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState) @@ -161,6 +194,14 @@ object BackupRepository { } } + val groups = SignalDatabase.groups.getGroups() + while (groups.hasNext()) { + val group = groups.next() + if (group.id.isV2) { + ApplicationDependencies.getJobManager().add(RequestGroupV2InfoJob(group.id as GroupId.V2)) + } + } + Log.d(TAG, "import() ${eventTimer.stop().summary}") } @@ -259,6 +300,11 @@ object BackupRepository { ) } +class ExportState { + val recipientIds = HashSet() + val threadIds = HashSet() +} + class BackupState { val backupToLocalRecipientId = HashMap() val chatIdToLocalThreadId = HashMap() diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt index 0f48979fa3..e2348c2357 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/CallTableBackupExtensions.kt @@ -39,17 +39,12 @@ fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupStat Call.Type.UNKNOWN_TYPE -> return } - val event = when (call.event) { - Call.Event.DELETE -> CallTable.Event.DELETE - Call.Event.JOINED -> CallTable.Event.JOINED - Call.Event.GENERIC_GROUP_CALL -> CallTable.Event.GENERIC_GROUP_CALL - Call.Event.DECLINED -> CallTable.Event.DECLINED - Call.Event.ACCEPTED -> CallTable.Event.ACCEPTED - Call.Event.MISSED -> CallTable.Event.MISSED - Call.Event.OUTGOING_RING -> CallTable.Event.OUTGOING_RING - Call.Event.OUTGOING -> CallTable.Event.ONGOING - Call.Event.NOT_ACCEPTED -> CallTable.Event.NOT_ACCEPTED - Call.Event.UNKNOWN_EVENT -> return + val event = when (call.state) { + Call.State.MISSED -> CallTable.Event.MISSED + Call.State.COMPLETED -> CallTable.Event.ACCEPTED + Call.State.DECLINED_BY_USER -> CallTable.Event.DECLINED + Call.State.DECLINED_BY_NOTIFICATION_PROFILE -> CallTable.Event.MISSED_NOTIFICATION_PROFILE + Call.State.UNKNOWN_EVENT -> return } val direction = if (call.outgoing) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING @@ -102,18 +97,18 @@ class CallLogIterator(private val cursor: Cursor) : Iterator, Close }, timestamp = cursor.requireLong(CallTable.TIMESTAMP), ringerRecipientId = if (cursor.isNull(CallTable.RINGER)) null else cursor.requireLong(CallTable.RINGER), - event = when (event) { - CallTable.Event.ONGOING -> Call.Event.OUTGOING - CallTable.Event.OUTGOING_RING -> Call.Event.OUTGOING_RING - CallTable.Event.ACCEPTED -> Call.Event.ACCEPTED - CallTable.Event.DECLINED -> Call.Event.DECLINED - CallTable.Event.GENERIC_GROUP_CALL -> Call.Event.GENERIC_GROUP_CALL - CallTable.Event.JOINED -> Call.Event.JOINED - CallTable.Event.MISSED, - CallTable.Event.MISSED_NOTIFICATION_PROFILE -> Call.Event.MISSED - CallTable.Event.DELETE -> Call.Event.DELETE - CallTable.Event.RINGING -> Call.Event.UNKNOWN_EVENT - CallTable.Event.NOT_ACCEPTED -> Call.Event.NOT_ACCEPTED + state = when (event) { + CallTable.Event.ONGOING -> Call.State.COMPLETED + CallTable.Event.OUTGOING_RING -> Call.State.COMPLETED + CallTable.Event.ACCEPTED -> Call.State.COMPLETED + CallTable.Event.DECLINED -> Call.State.DECLINED_BY_USER + CallTable.Event.GENERIC_GROUP_CALL -> Call.State.COMPLETED + CallTable.Event.JOINED -> Call.State.COMPLETED + CallTable.Event.MISSED -> Call.State.MISSED + CallTable.Event.MISSED_NOTIFICATION_PROFILE -> Call.State.DECLINED_BY_NOTIFICATION_PROFILE + CallTable.Event.DELETE -> Call.State.COMPLETED + CallTable.Event.RINGING -> Call.State.MISSED + CallTable.Event.NOT_ACCEPTED -> Call.State.MISSED } ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index bf0245b165..4228bdf28d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -154,7 +154,6 @@ class ChatItemImportInserter( if (buffer.size == 0) { return false } - buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages).forEach { db.rawQuery("${it.query.where} RETURNING ${MessageTable.ID}", it.query.whereArgs).use { cursor -> var index = 0 @@ -179,6 +178,8 @@ class ChatItemImportInserter( messageId = SqlUtil.getNextAutoIncrementId(db, MessageTable.TABLE_NAME) + buffer.reset() + return true } @@ -245,6 +246,7 @@ class ChatItemImportInserter( contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, 0) contentValues.put(MessageTable.UNIDENTIFIED, this.sealedSender?.toInt()) contentValues.put(MessageTable.READ, this.incoming?.read?.toInt() ?: 0) + contentValues.put(MessageTable.NOTIFIED, 1) } contentValues.put(MessageTable.QUOTE_ID, 0) @@ -267,7 +269,6 @@ class ChatItemImportInserter( val reactions: List = when { this.standardMessage != null -> this.standardMessage.reactions this.contactMessage != null -> this.contactMessage.reactions - this.voiceMessage != null -> this.voiceMessage.reactions this.stickerMessage != null -> this.stickerMessage.reactions else -> emptyList() } @@ -525,5 +526,11 @@ class ChatItemImportInserter( ) { val size: Int get() = listOf(messages.size, reactions.size, groupReceipts.size).max() + + fun reset() { + messages.clear() + reactions.clear() + groupReceipts.clear() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt index 919251b0bc..732ce85425 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DistributionListTablesBackupExtensions.kt @@ -28,6 +28,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.DistributionList as BackupDist private val TAG = Log.tag(DistributionListTables::class.java) +data class DistributionRecipient(val id: RecipientId, val record: DistributionListRecord) + fun DistributionListTables.getAllForBackup(): List { val records = readableDatabase .select() @@ -36,30 +38,34 @@ fun DistributionListTables.getAllForBackup(): List { .readToList { cursor -> val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID)) val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer) - - DistributionListRecord( - id = id, - name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME), - distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)), - allowsReplies = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.ALLOWS_REPLIES), - rawMembers = getRawMembers(id, privacyMode), - members = getMembers(id), - deletedAtTimestamp = 0L, - isUnknown = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.IS_UNKNOWN), - privacyMode = privacyMode + val recipientId: RecipientId = RecipientId.from(cursor.requireLong(DistributionListTables.ListTable.RECIPIENT_ID)) + DistributionRecipient( + id = recipientId, + record = DistributionListRecord( + id = id, + name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME), + distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)), + allowsReplies = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.ALLOWS_REPLIES), + rawMembers = getRawMembers(id, privacyMode), + members = getMembers(id), + deletedAtTimestamp = 0L, + isUnknown = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.IS_UNKNOWN), + privacyMode = privacyMode + ) ) } return records - .map { record -> + .map { recipient -> BackupRecipient( + id = recipient.id.toLong(), distributionList = BackupDistributionList( - name = record.name, - distributionId = record.distributionId.asUuid().toByteArray().toByteString(), - allowReplies = record.allowsReplies, - deletionTimestamp = record.deletedAtTimestamp, - privacyMode = record.privacyMode.toBackupPrivacyMode(), - memberRecipientIds = record.members.map { it.toLong() } + name = recipient.record.name, + distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(), + allowReplies = recipient.record.allowsReplies, + deletionTimestamp = recipient.record.deletedAtTimestamp, + privacyMode = recipient.record.privacyMode.toBackupPrivacyMode(), + memberRecipientIds = recipient.record.members.map { it.toLong() } ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt index c669acb1e3..cf2de709db 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/RecipientTableBackupExtensions.kt @@ -22,23 +22,30 @@ import org.signal.core.util.select import org.signal.core.util.toInt import org.signal.core.util.update import org.signal.libsignal.zkgroup.InvalidInputException +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.signal.storageservice.protos.groups.local.DecryptedGroup import org.thoughtcrime.securesms.backup.v2.BackupState import org.thoughtcrime.securesms.backup.v2.proto.AccountData import org.thoughtcrime.securesms.backup.v2.proto.Contact import org.thoughtcrime.securesms.backup.v2.proto.Group import org.thoughtcrime.securesms.backup.v2.proto.Self +import org.thoughtcrime.securesms.conversation.colors.AvatarColorHash import org.thoughtcrime.securesms.database.GroupTable import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.RecipientTableCursorUtil import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.whispersystems.signalservice.api.push.ServiceId.ACI import org.whispersystems.signalservice.api.push.ServiceId.PNI +import org.whispersystems.signalservice.api.util.toByteArray import java.io.Closeable typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient @@ -94,7 +101,8 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator { "${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL}", "${RecipientTable.TABLE_NAME}.${RecipientTable.EXTRAS}", "${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}", - "${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}" + "${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}", + "${GroupTable.TABLE_NAME}.${GroupTable.TITLE}" ) .from( """ @@ -102,6 +110,7 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator { INNER JOIN ${GroupTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID} """ ) + .where("${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY} IS NOT NULL") .run() return BackupGroupIterator(cursor) @@ -115,6 +124,7 @@ fun RecipientTable.restoreRecipientFromBackup(recipient: BackupRecipient, backup // TODO Also, should we move this when statement up to mimic the export? Kinda weird that this calls distributionListTable functions return when { recipient.contact != null -> restoreContactFromBackup(recipient.contact) + recipient.group != null -> restoreGroupFromBackup(recipient.group) recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState) recipient.self != null -> Recipient.self().id else -> { @@ -193,6 +203,41 @@ private fun RecipientTable.restoreContactFromBackup(contact: Contact): Recipient return id } +private fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId { + val masterKey = GroupMasterKey(group.masterKey.toByteArray()) + val groupId = GroupId.v2(masterKey) + + val placeholderState = DecryptedGroup.Builder() + .revision(GroupsV2StateProcessor.PLACEHOLDER_REVISION) + .build() + + val values = ContentValues().apply { + put(RecipientTable.GROUP_ID, groupId.toString()) + put(RecipientTable.AVATAR_COLOR, AvatarColorHash.forGroupId(groupId).serialize()) + put(RecipientTable.PROFILE_SHARING, group.whitelisted) + put(RecipientTable.TYPE, RecipientTable.RecipientType.GV2.id) + put(RecipientTable.STORAGE_SERVICE_ID, Base64.encodeWithPadding(StorageSyncHelper.generateKey())) + if (group.hideStory) { + val extras = RecipientExtras.Builder().hideStory(true).build() + put(RecipientTable.EXTRAS, extras.encode()) + } + } + + val recipientId = writableDatabase.insert(RecipientTable.TABLE_NAME, null, values) + val groupValues = ContentValues().apply { + put(GroupTable.RECIPIENT_ID, recipientId) + put(GroupTable.GROUP_ID, groupId.toString()) + put(GroupTable.TITLE, group.name) + put(GroupTable.V2_MASTER_KEY, masterKey.serialize()) + put(GroupTable.V2_DECRYPTED_GROUP, placeholderState.encode()) + put(GroupTable.V2_REVISION, placeholderState.revision) + put(GroupTable.SHOW_AS_STORY_STATE, group.storySendMode.toGroupShowAsStoryState().code) + } + writableDatabase.insert(GroupTable.TABLE_NAME, null, groupValues) + + return RecipientId.from(recipientId) +} + private fun Contact.toLocalExtras(): RecipientExtras { return RecipientExtras( hideStory = this.hideStory @@ -235,8 +280,8 @@ class BackupContactIterator(private val cursor: Cursor, private val selfId: Long return BackupRecipient( id = id, contact = Contact( - aci = aci?.toByteArray()?.toByteString(), - pni = pni?.toByteArray()?.toByteString(), + aci = aci?.rawUuid?.toByteArray()?.toByteString(), + pni = pni?.rawUuid?.toByteArray()?.toByteString(), username = cursor.requireString(RecipientTable.USERNAME), e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong(), blocked = cursor.requireBoolean(RecipientTable.BLOCKED), @@ -280,7 +325,8 @@ class BackupGroupIterator(private val cursor: Cursor) : Iterator GroupTable.ShowAsStoryState.ALWAYS + Group.StorySendMode.DISABLED -> GroupTable.ShowAsStoryState.NEVER + Group.StorySendMode.DEFAULT -> GroupTable.ShowAsStoryState.IF_ACTIVE + } +} + private val Contact.formattedE164: String? get() { return e164?.let { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt index e10d0921e2..a6e13af11f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatBackupProcessor.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.processor import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.ExportState import org.thoughtcrime.securesms.backup.v2.database.getThreadsForBackup import org.thoughtcrime.securesms.backup.v2.database.restoreFromBackup import org.thoughtcrime.securesms.backup.v2.proto.Chat @@ -18,10 +19,15 @@ import org.thoughtcrime.securesms.recipients.RecipientId object ChatBackupProcessor { val TAG = Log.tag(ChatBackupProcessor::class.java) - fun export(emitter: BackupFrameEmitter) { + fun export(exportState: ExportState, emitter: BackupFrameEmitter) { SignalDatabase.threads.getThreadsForBackup().use { reader -> for (chat in reader) { - emitter.emit(Frame(chat = chat)) + if (exportState.recipientIds.contains(chat.recipientId)) { + exportState.threadIds.add(chat.id) + emitter.emit(Frame(chat = chat)) + } else { + Log.w(TAG, "dropping thread for deleted recipient ${chat.recipientId}") + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt index e739fc9a28..ef1aa6ab57 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/ChatItemBackupProcessor.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.processor import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.ExportState import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter import org.thoughtcrime.securesms.backup.v2.database.createChatItemInserter import org.thoughtcrime.securesms.backup.v2.database.getMessagesForBackup @@ -17,10 +18,12 @@ import org.thoughtcrime.securesms.database.SignalDatabase object ChatItemBackupProcessor { val TAG = Log.tag(ChatItemBackupProcessor::class.java) - fun export(emitter: BackupFrameEmitter) { + fun export(exportState: ExportState, emitter: BackupFrameEmitter) { SignalDatabase.messages.getMessagesForBackup().use { chatItems -> for (chatItem in chatItems) { - emitter.emit(Frame(chatItem = chatItem)) + if (exportState.threadIds.contains(chatItem.chatId)) { + emitter.emit(Frame(chatItem = chatItem)) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt index 0c8db3753e..a3b90bde8e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/processor/RecipientBackupProcessor.kt @@ -7,13 +7,17 @@ package org.thoughtcrime.securesms.backup.v2.processor import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.backup.v2.BackupState +import org.thoughtcrime.securesms.backup.v2.ExportState +import org.thoughtcrime.securesms.backup.v2.database.BackupRecipient import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup import org.thoughtcrime.securesms.backup.v2.database.getGroupsForBackup import org.thoughtcrime.securesms.backup.v2.database.restoreRecipientFromBackup import org.thoughtcrime.securesms.backup.v2.proto.Frame +import org.thoughtcrime.securesms.backup.v2.proto.ReleaseNotes import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient @@ -22,7 +26,7 @@ object RecipientBackupProcessor { val TAG = Log.tag(RecipientBackupProcessor::class.java) - fun export(emitter: BackupFrameEmitter) { + fun export(state: ExportState, emitter: BackupFrameEmitter) { val selfId = Recipient.self().id.toLong() SignalDatabase.recipients.getContactsForBackup(selfId).use { reader -> @@ -35,13 +39,27 @@ object RecipientBackupProcessor { SignalDatabase.recipients.getGroupsForBackup().use { reader -> for (backupRecipient in reader) { + state.recipientIds.add(backupRecipient.id) emitter.emit(Frame(recipient = backupRecipient)) } } SignalDatabase.distributionLists.getAllForBackup().forEach { + state.recipientIds.add(it.id) emitter.emit(Frame(recipient = it)) } + + val releaseChannelId = SignalStore.releaseChannelValues().releaseChannelRecipientId + if (releaseChannelId != null) { + emitter.emit( + Frame( + recipient = BackupRecipient( + id = releaseChannelId.toLong(), + releaseNotes = ReleaseNotes() + ) + ) + ) + } } fun import(recipient: BackupRecipient, backupState: BackupState) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupExportWriter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupExportWriter.kt index 24d3e34eb3..3c8023d95f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupExportWriter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupExportWriter.kt @@ -5,8 +5,10 @@ package org.thoughtcrime.securesms.backup.v2.stream +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.proto.Frame interface BackupExportWriter : AutoCloseable { + fun write(header: BackupInfo) fun write(frame: Frame) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportReader.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportReader.kt new file mode 100644 index 0000000000..f5b5e4d24c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/BackupImportReader.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.stream + +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo +import org.thoughtcrime.securesms.backup.v2.proto.Frame + +interface BackupImportReader : Iterator, AutoCloseable { + fun getHeader(): BackupInfo? +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt index c32da6ab00..1c597850b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReader.kt @@ -10,6 +10,7 @@ import org.signal.core.util.readNBytesOrThrow import org.signal.core.util.readVarInt32 import org.signal.core.util.stream.MacInputStream import org.signal.core.util.stream.TruncatingInputStream +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.proto.Frame import org.whispersystems.signalservice.api.backup.BackupKey import org.whispersystems.signalservice.api.push.ServiceId.ACI @@ -33,8 +34,9 @@ class EncryptedBackupReader( aci: ACI, streamLength: Long, dataStream: () -> InputStream -) : Iterator, AutoCloseable { +) : BackupImportReader { + val backupInfo: BackupInfo? var next: Frame? = null val stream: InputStream @@ -56,10 +58,14 @@ class EncryptedBackupReader( cipher ) ) - + backupInfo = readHeader() next = read() } + override fun getHeader(): BackupInfo? { + return backupInfo + } + override fun hasNext(): Boolean { return next != null } @@ -71,6 +77,17 @@ class EncryptedBackupReader( } ?: throw NoSuchElementException() } + private fun readHeader(): BackupInfo? { + try { + val length = stream.readVarInt32().takeIf { it >= 0 } ?: return null + val headerBytes: ByteArray = stream.readNBytesOrThrow(length) + + return BackupInfo.ADAPTER.decode(headerBytes) + } catch (e: EOFException) { + return null + } + } + private fun read(): Frame? { try { val length = stream.readVarInt32().also { if (it < 0) return null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupWriter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupWriter.kt index 530f383195..c8d2ea4de9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupWriter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupWriter.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.stream import org.signal.core.util.stream.MacOutputStream import org.signal.core.util.writeVarInt32 +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.proto.Frame import org.whispersystems.signalservice.api.backup.BackupKey import org.whispersystems.signalservice.api.push.ServiceId.ACI @@ -56,6 +57,13 @@ class EncryptedBackupWriter( ) } + override fun write(header: BackupInfo) { + val headerBytes = header.encode() + + mainStream.writeVarInt32(headerBytes.size) + mainStream.write(headerBytes) + } + @Throws(IOException::class) override fun write(frame: Frame) { val frameBytes: ByteArray = frame.encode() diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupReader.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupReader.kt index 91c9945d9d..136ee50dd0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupReader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupReader.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.stream import org.signal.core.util.readNBytesOrThrow import org.signal.core.util.readVarInt32 +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.proto.Frame import java.io.EOFException import java.io.InputStream @@ -14,14 +15,20 @@ import java.io.InputStream /** * Reads a plaintext backup import stream one frame at a time. */ -class PlainTextBackupReader(val inputStream: InputStream) : Iterator { +class PlainTextBackupReader(val inputStream: InputStream) : BackupImportReader { + val backupInfo: BackupInfo? var next: Frame? = null init { + backupInfo = readHeader() next = read() } + override fun getHeader(): BackupInfo? { + return backupInfo + } + override fun hasNext(): Boolean { return next != null } @@ -33,6 +40,21 @@ class PlainTextBackupReader(val inputStream: InputStream) : Iterator { } ?: throw NoSuchElementException() } + override fun close() { + inputStream.close() + } + + private fun readHeader(): BackupInfo? { + try { + val length = inputStream.readVarInt32().takeIf { it >= 0 } ?: return null + val headerBytes: ByteArray = inputStream.readNBytesOrThrow(length) + + return BackupInfo.ADAPTER.decode(headerBytes) + } catch (e: EOFException) { + return null + } + } + private fun read(): Frame? { try { val length = inputStream.readVarInt32().also { if (it < 0) return null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupWriter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupWriter.kt index a4e414dcba..b56f1627b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupWriter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/stream/PlainTextBackupWriter.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.backup.v2.stream import org.signal.core.util.writeVarInt32 +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.proto.Frame import java.io.IOException import java.io.OutputStream @@ -15,6 +16,14 @@ import java.io.OutputStream */ class PlainTextBackupWriter(private val outputStream: OutputStream) : BackupExportWriter { + @Throws(IOException::class) + override fun write(header: BackupInfo) { + val headerBytes: ByteArray = header.encode() + + outputStream.writeVarInt32(headerBytes.size) + outputStream.write(headerBytes) + } + @Throws(IOException::class) override fun write(frame: Frame) { val frameBytes: ByteArray = frame.encode() diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index 5b32b5e547..9d3fd8410a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -48,6 +48,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { private val viewModel: InternalBackupPlaygroundViewModel by viewModels() private lateinit var exportFileLauncher: ActivityResultLauncher private lateinit var importFileLauncher: ActivityResultLauncher + private lateinit var validateFileLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -72,6 +73,16 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { } ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show() } } + + validateFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.data?.let { uri -> + requireContext().contentResolver.getLength(uri)?.let { length -> + viewModel.validate(length) { requireContext().contentResolver.openInputStream(uri)!! } + } + } ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show() + } + } } @Composable @@ -103,7 +114,16 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { exportFileLauncher.launch(intent) }, onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() }, - onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() } + onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() }, + onValidateFileClicked = { + val intent = Intent().apply { + action = Intent.ACTION_GET_CONTENT + type = "application/octet-stream" + addCategory(Intent.CATEGORY_OPENABLE) + } + + validateFileLauncher.launch(intent) + } ) } @@ -120,6 +140,7 @@ fun Screen( onImportFileClicked: () -> Unit = {}, onPlaintextClicked: () -> Unit = {}, onSaveToDiskClicked: () -> Unit = {}, + onValidateFileClicked: () -> Unit = {}, onUploadToRemoteClicked: () -> Unit = {}, onCheckRemoteBackupStateClicked: () -> Unit = {} ) { @@ -165,6 +186,12 @@ fun Screen( Text("Import from file") } + Buttons.LargeTonal( + onClick = onValidateFileClicked + ) { + Text("Validate file") + } + Spacer(modifier = Modifier.height(16.dp)) when (state.backupState) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt index 58d9626a06..a178b178ef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt @@ -78,6 +78,19 @@ class InternalBackupPlaygroundViewModel : ViewModel() { } } + fun validate(length: Long, inputStreamFactory: () -> InputStream) { + val self = Recipient.self() + val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey)) + + disposables += Single.fromCallable { BackupRepository.validate(length, inputStreamFactory, selfData) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { nothing -> + backupData = null + _state.value = _state.value.copy(backupState = BackupState.NONE) + } + } + fun onPlaintextToggled() { _state.value = _state.value.copy(plaintext = !_state.value.plaintext) } diff --git a/app/src/main/protowire/Backup.proto b/app/src/main/protowire/Backup.proto index f45c085930..3897400c43 100644 --- a/app/src/main/protowire/Backup.proto +++ b/app/src/main/protowire/Backup.proto @@ -121,6 +121,7 @@ message Group { bool whitelisted = 2; bool hideStory = 3; StorySendMode storySendMode = 4; + string name = 5; } message Self {} @@ -173,17 +174,12 @@ message Call { AD_HOC_CALL = 4; } - enum Event { + enum State { UNKNOWN_EVENT = 0; - OUTGOING = 1; // 1:1 calls only - ACCEPTED = 2; // 1:1 and group calls. Group calls: You accepted a ring. - NOT_ACCEPTED = 3; // 1:1 calls only, - MISSED = 4; // 1:1 and group. Group calls: The remote ring has expired or was cancelled by the ringer. - DELETE = 5; // 1:1 and Group/Ad-Hoc Calls. - GENERIC_GROUP_CALL = 6; // Group/Ad-Hoc Calls only. Initial state - JOINED = 7; // Group Calls: User has joined the group call. - DECLINED = 8; // Group Calls: If you declined a ring. - OUTGOING_RING = 9; // Group Calls: If you are ringing a group. + COMPLETED = 1; // A call that was successfully completed or was accepted and in-progress at the time of the backup. + DECLINED_BY_USER = 2; // An incoming call that was manually declined by the user. + DECLINED_BY_NOTIFICATION_PROFILE = 3; // An incoming call that was automatically declined by an active notification profile. + MISSED = 4; // An incoming call that either expired, was cancelled by the sender, or was auto-rejected due to already being in a different call. } uint64 callId = 1; @@ -192,7 +188,7 @@ message Call { bool outgoing = 4; uint64 timestamp = 5; optional uint64 ringerRecipientId = 6; - Event event = 7; + State state = 7; } message ChatItem { @@ -227,10 +223,9 @@ message ChatItem { oneof item { StandardMessage standardMessage = 13; ContactMessage contactMessage = 14; - VoiceMessage voiceMessage = 15; - StickerMessage stickerMessage = 16; - RemoteDeletedMessage remoteDeletedMessage = 17; - ChatUpdateMessage updateMessage = 18; + StickerMessage stickerMessage = 15; + RemoteDeletedMessage remoteDeletedMessage = 16; + ChatUpdateMessage updateMessage = 17; } } @@ -262,7 +257,7 @@ message Text { message StandardMessage { optional Quote quote = 1; optional Text text = 2; - repeated FilePointer attachments = 3; + repeated MessageAttachment attachments = 3; repeated LinkPreview linkPreview = 4; optional FilePointer longText = 5; repeated Reaction reactions = 6; @@ -330,15 +325,11 @@ message ContactAttachment { optional string country = 9; } - message Avatar { - FilePointer avatar = 1; - } - optional Name name = 1; repeated Phone number = 3; repeated Email email = 4; repeated PostalAddress address = 5; - optional Avatar avatar = 6; + optional string avatarUrlPath = 6; optional string organization = 7; } @@ -348,12 +339,6 @@ message DocumentMessage { repeated Reaction reactions = 3; } -message VoiceMessage { - optional Quote quote = 1; - FilePointer audio = 2; - repeated Reaction reactions = 3; -} - message StickerMessage { Sticker sticker = 1; repeated Reaction reactions = 2; @@ -367,6 +352,11 @@ message Sticker { bytes packKey = 2; uint32 stickerId = 3; optional string emoji = 4; + // Stickers are uploaded to be sent as attachments; we also + // back them up as normal attachments when they are in messages. + // DO NOT treat this as the definitive source of a sticker in + // an installed StickerPack that shares the same packId. + FilePointer data = 5; } message LinkPreview { @@ -377,23 +367,42 @@ message LinkPreview { optional uint64 date = 5; } +// A FilePointer on a message that has additional +// metadata that applies only to message attachments. +message MessageAttachment { + // Similar to SignalService.AttachmentPointer.Flags, + // but explicitly mutually exclusive. Note the different raw values + // (non-zero starting values are not supported in proto3.) + enum Flag { + NONE = 0; + VOICE_MESSAGE = 1; + BORDERLESS = 2; + GIF = 3; + } + + FilePointer pointer = 1; + Flag flag = 2; +} + message FilePointer { + // References attachments in the backup (media) storage tier. message BackupLocator { string mediaName = 1; uint32 cdnNumber = 2; } + // References attachments in the transit storage tier. + // May be downloaded or not when the backup is generated; + // primarily for free-tier users who cannot copy the + // attachments to the backup (media) storage tier. message AttachmentLocator { string cdnKey = 1; uint32 cdnNumber = 2; uint64 uploadTimestamp = 3; } - message LegacyAttachmentLocator { - fixed64 cdnId = 1; - } - - // An attachment that was backed up without being downloaded. + // An attachment that was copied from the transit storage tier + // to the backup (media) storage tier up without being downloaded. // Its MediaName should be generated as “{sender_aci}_{cdn_attachment_key}”, // but should eventually transition to a BackupLocator with mediaName // being the content hash once it is downloaded. @@ -403,17 +412,10 @@ message FilePointer { uint32 cdnNumber = 3; } - enum Flags { - VOICE_MESSAGE = 0; - BORDERLESS = 1; - GIF = 2; - } - oneof locator { BackupLocator backupLocator = 1; AttachmentLocator attachmentLocator= 2; - LegacyAttachmentLocator legacyAttachmentLocator = 3; - UndownloadedBackupLocator undownloadedBackupLocator = 4; + UndownloadedBackupLocator undownloadedBackupLocator = 3; } optional bytes key = 5; @@ -424,11 +426,10 @@ message FilePointer { optional bytes incrementalMac = 8; optional bytes incrementalMacChunkSize = 9; optional string fileName = 10; - optional uint32 flags = 11; - optional uint32 width = 12; - optional uint32 height = 13; - optional string caption = 14; - optional string blurHash = 15; + optional uint32 width = 11; + optional uint32 height = 12; + optional string caption = 13; + optional string blurHash = 14; } message Quote { @@ -441,7 +442,7 @@ message Quote { message QuotedAttachment { optional string contentType = 1; optional string fileName = 2; - optional FilePointer thumbnail = 3; + optional MessageAttachment thumbnail = 3; } optional uint64 targetSentTimestamp = 1; // null if the target message could not be found at time of quote insert @@ -509,11 +510,12 @@ message IndividualCallChatUpdate { MISSED_AUDIO_CALL = 5; MISSED_VIDEO_CALL = 6; } + Type type = 1; } message GroupCallChatUpdate { - bytes startedCallAci = 1; + optional bytes startedCallAci = 1; uint64 startedCallTimestamp = 2; repeated bytes inCallAcis = 3; } @@ -658,7 +660,7 @@ message GroupAdminStatusUpdate { } message GroupMemberLeftUpdate { - optional bytes aci = 1; + bytes aci = 1; } message GroupMemberRemovedUpdate { @@ -787,14 +789,14 @@ message GroupV2MigrationSelfInvitedUpdate {} // add some members and invited them instead. // (Happens if we don't have the invitee's profile key) message GroupV2MigrationInvitedMembersUpdate { - int32 invitedMembersCount = 1; + uint32 invitedMembersCount = 1; } // The local user migrated gv1->gv2 but was unable to // add or invite some members and dropped them instead. // (Happens for e164 members where we don't have an aci). message GroupV2MigrationDroppedMembersUpdate { - int32 droppedMembersCount = 1; + uint32 droppedMembersCount = 1; } // For 1:1 timer updates, use ExpirationTimerChatUpdate. @@ -804,14 +806,14 @@ message GroupExpirationTimerUpdate { } message StickerPack { - bytes id = 1; - bytes key = 2; + bytes packId = 1; + bytes packKey = 2; string title = 3; string author = 4; repeated StickerPackSticker stickers = 5; // First one should be cover sticker. } message StickerPackSticker { - FilePointer data = 1; - string emoji = 2; -} \ No newline at end of file + string emoji = 1; + uint32 id = 2; +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReaderWriterTest.kt b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReaderWriterTest.kt index 48d14a4683..12198e7e43 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReaderWriterTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/backup/v2/stream/EncryptedBackupReaderWriterTest.kt @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.backup.v2.stream import org.junit.Assert.assertEquals import org.junit.Test import org.thoughtcrime.securesms.backup.v2.proto.AccountData +import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo import org.thoughtcrime.securesms.backup.v2.proto.Frame import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.api.backup.BackupKey @@ -26,6 +27,7 @@ class EncryptedBackupReaderWriterTest { val frameCount = 10_000 EncryptedBackupWriter(key, aci, outputStream, append = { outputStream.write(it) }).use { writer -> + writer.write(BackupInfo(1, 1000L)) for (i in 0 until frameCount) { writer.write(Frame(account = AccountData(username = "username-$i"))) } @@ -34,6 +36,8 @@ class EncryptedBackupReaderWriterTest { val ciphertext: ByteArray = outputStream.toByteArray() val frames: List = EncryptedBackupReader(key, aci, ciphertext.size.toLong()) { ciphertext.inputStream() }.use { reader -> + assertEquals(reader.backupInfo?.version, 1L) + assertEquals(reader.backupInfo?.backupTimeMs, 1000L) reader.asSequence().toList() }