diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ArchiveImportExportTests.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ArchiveImportExportTests.kt index e010a89caa..1427e86994 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ArchiveImportExportTests.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/backup/v2/ArchiveImportExportTests.kt @@ -300,20 +300,6 @@ class ArchiveImportExportTests { ) } - private fun assertPassesValidator(testName: String, generatedBackupData: ByteArray): TestResult.Failure? { - try { - BackupRepository.validate( - length = generatedBackupData.size.toLong(), - inputStreamFactory = { ByteArrayInputStream(generatedBackupData) }, - selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, ProfileKey(SELF_PROFILE_KEY)) - ) - } catch (e: Exception) { - return TestResult.Failure(testName, "Generated backup failed validation: ${e.message}") - } - - return null - } - private fun checkEquivalent(testName: String, import: ByteArray, export: ByteArray): TestResult.Failure? { val importComparable = try { ComparableBackup.readUnencrypted(MessageBackup.Purpose.REMOTE_BACKUP, import.inputStream(), import.size.toLong()) 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 bd1b26494a..13802b71f6 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 @@ -33,9 +33,6 @@ import org.signal.core.util.requireNonNullString import org.signal.core.util.stream.NonClosingOutputStream import org.signal.core.util.urlEncode import org.signal.core.util.withinTransaction -import org.signal.libsignal.messagebackup.MessageBackup -import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult -import org.signal.libsignal.protocol.ServiceId.Aci import org.signal.libsignal.zkgroup.backups.BackupLevel import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.attachments.Attachment @@ -109,7 +106,6 @@ import java.util.concurrent.atomic.AtomicLong import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds -import org.signal.libsignal.messagebackup.MessageBackupKey as LibSignalMessageBackupKey object BackupRepository { @@ -894,13 +890,6 @@ object BackupRepository { return ImportResult.Success(backupTime = header.backupTimeMs) } - fun validate(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData): ValidationResult { - val accountEntropyPool = SignalStore.account.accountEntropyPool.value - val key = LibSignalMessageBackupKey(accountEntropyPool, Aci.parseFromBinary(selfData.aci.toByteArray())) - - return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, inputStreamFactory, length) - } - fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult { return initBackupAndFetchAuth() .then { credential -> 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 342e352a65..232248493d 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 @@ -7,7 +7,6 @@ package org.thoughtcrime.securesms.components.settings.app.internal.backup import android.app.Activity.RESULT_OK import android.content.Intent -import android.content.res.Configuration import android.os.Bundle import android.widget.Toast import androidx.activity.result.ActivityResultLauncher @@ -23,13 +22,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu @@ -42,7 +39,6 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -67,18 +63,15 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.signal.core.ui.Buttons import org.signal.core.ui.Dividers +import org.signal.core.ui.Previews +import org.signal.core.ui.Rows +import org.signal.core.ui.SignalPreview import org.signal.core.ui.Snackbars -import org.signal.core.ui.theme.SignalTheme -import org.signal.core.util.bytes import org.signal.core.util.getLength -import org.signal.core.util.roundedString import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.backup.v2.MessageBackupTier -import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupState -import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupUploadState import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.jobs.LocalBackupJob @@ -87,46 +80,47 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore class InternalBackupPlaygroundFragment : ComposeFragment() { private val viewModel: InternalBackupPlaygroundViewModel by viewModels() - private lateinit var exportFileLauncher: ActivityResultLauncher - private lateinit var importFileLauncher: ActivityResultLauncher - private lateinit var validateFileLauncher: ActivityResultLauncher - private lateinit var savePlaintextcopyLauncher: ActivityResultLauncher + private lateinit var saveEncryptedBackupToDiskLauncher: ActivityResultLauncher + private lateinit var savePlaintextBackupToDiskLauncher: ActivityResultLauncher + private lateinit var importEncryptedBackupFromDiskLauncher: ActivityResultLauncher + private lateinit var savePlaintextCopyLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - exportFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + saveEncryptedBackupToDiskLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { result.data?.data?.let { uri -> - requireContext().contentResolver.openOutputStream(uri)?.use { outputStream -> - outputStream.write(viewModel.backupData!!) - Toast.makeText(requireContext(), "Saved successfully", Toast.LENGTH_SHORT).show() - } ?: Toast.makeText(requireContext(), "Failed to open output stream", Toast.LENGTH_SHORT).show() + viewModel.exportEncrypted( + openStream = { requireContext().contentResolver.openOutputStream(uri)!! }, + appendStream = { requireContext().contentResolver.openOutputStream(uri, "wa")!! } + ) } ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show() } } - importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + savePlaintextBackupToDiskLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) { + result.data?.data?.let { uri -> + viewModel.exportPlaintext( + openStream = { requireContext().contentResolver.openOutputStream(uri)!! }, + appendStream = { requireContext().contentResolver.openOutputStream(uri, "wa")!! } + ) + } ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show() + } + } + + importEncryptedBackupFromDiskLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { result.data?.data?.let { uri -> requireContext().contentResolver.getLength(uri)?.let { length -> - viewModel.import(length) { requireContext().contentResolver.openInputStream(uri)!! } + viewModel.importEncryptedBackup(length) { requireContext().contentResolver.openInputStream(uri)!! } } } ?: 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() - } - } - - savePlaintextcopyLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + savePlaintextCopyLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == RESULT_OK) { result.data?.data?.let { uri -> viewModel.fetchRemoteBackupAndWritePlaintext(requireContext().contentResolver.openOutputStream(uri)) @@ -152,54 +146,32 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { mainContent = { Screen( state = state, - onExportClicked = { viewModel.export() }, - onExportDirectoryClicked = { LocalBackupJob.enqueueArchive() }, - onImportMemoryClicked = { viewModel.import() }, - onImportFileClicked = { - val intent = Intent().apply { - action = Intent.ACTION_GET_CONTENT - type = "application/octet-stream" - addCategory(Intent.CATEGORY_OPENABLE) - } - - importFileLauncher.launch(intent) - }, - onImportDirectoryClicked = { - viewModel.import(SignalStore.settings.signalBackupDirectory!!) - }, - onPlaintextClicked = { viewModel.onPlaintextToggled() }, - onSaveToDiskClicked = { + onBackupTierSelected = { tier -> viewModel.onBackupTierSelected(tier) }, + onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() }, + onEnqueueRemoteBackupClicked = { viewModel.triggerBackupJob() }, + onHaltAllBackupJobsClicked = { viewModel.haltAllJobs() }, + onValidateBackupClicked = { viewModel.validateBackup() }, + onSaveEncryptedBackupToDiskClicked = { val intent = Intent().apply { action = Intent.ACTION_CREATE_DOCUMENT type = "application/octet-stream" addCategory(Intent.CATEGORY_OPENABLE) - putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin") + putExtra(Intent.EXTRA_TITLE, "backup-encrypted-${System.currentTimeMillis()}.bin") } - exportFileLauncher.launch(intent) + saveEncryptedBackupToDiskLauncher.launch(intent) }, - onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() }, - onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() }, - onValidateFileClicked = { + onSavePlaintextBackupToDiskClicked = { val intent = Intent().apply { - action = Intent.ACTION_GET_CONTENT + action = Intent.ACTION_CREATE_DOCUMENT type = "application/octet-stream" addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, "backup-plaintext-${System.currentTimeMillis()}.bin") } - validateFileLauncher.launch(intent) + savePlaintextBackupToDiskLauncher.launch(intent) }, - onTriggerBackupJobClicked = { viewModel.triggerBackupJob() }, - onWipeDataAndRestoreClicked = { - MaterialAlertDialogBuilder(context) - .setTitle("Are you sure?") - .setMessage("This will delete all of your chats! Make sure you've finished a backup first, we don't check for you. Only do this on a test device!") - .setPositiveButton("Wipe and restore") { _, _ -> viewModel.wipeAllDataAndRestoreFromRemote() } - .show() - }, - onBackupTierSelected = { tier -> viewModel.onBackupTierSelected(tier) }, - onHaltAllJobs = { viewModel.haltAllJobs() }, - onSavePlaintextCopy = { + onSavePlaintextCopyOfRemoteBackupClicked = { val intent = Intent().apply { action = Intent.ACTION_CREATE_DOCUMENT type = "application/octet-stream" @@ -207,7 +179,31 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { putExtra(Intent.EXTRA_TITLE, "backup-plaintext-${System.currentTimeMillis()}.binproto") } - savePlaintextcopyLauncher.launch(intent) + savePlaintextCopyLauncher.launch(intent) + }, + onExportNewStyleLocalBackupClicked = { LocalBackupJob.enqueueArchive() }, + onWipeDataAndRestoreFromRemoteClicked = { + MaterialAlertDialogBuilder(context) + .setTitle("Are you sure?") + .setMessage("This will delete all of your chats! Make sure you've finished a backup first, we don't check for you. Only do this on a test device!") + .setPositiveButton("Wipe and restore") { _, _ -> viewModel.wipeAllDataAndRestoreFromRemote() } + .show() + }, + onImportEncryptedBackupFromDiskClicked = { + val intent = Intent().apply { + action = Intent.ACTION_GET_CONTENT + type = "application/octet-stream" + addCategory(Intent.CATEGORY_OPENABLE) + } + + importEncryptedBackupFromDiskLauncher.launch(intent) + }, + onImportNewStyleLocalBackupClicked = { + 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!!) } + .show() } ) }, @@ -290,21 +286,18 @@ fun Tabs( @Composable fun Screen( state: ScreenState, - onExportClicked: () -> Unit = {}, - onExportDirectoryClicked: () -> Unit = {}, - onImportMemoryClicked: () -> Unit = {}, - onImportFileClicked: () -> Unit = {}, - onImportDirectoryClicked: () -> Unit = {}, - onPlaintextClicked: () -> Unit = {}, - onSaveToDiskClicked: () -> Unit = {}, - onValidateFileClicked: () -> Unit = {}, - onUploadToRemoteClicked: () -> Unit = {}, + onExportNewStyleLocalBackupClicked: () -> Unit = {}, + onImportNewStyleLocalBackupClicked: () -> Unit = {}, onCheckRemoteBackupStateClicked: () -> Unit = {}, - onTriggerBackupJobClicked: () -> Unit = {}, - onWipeDataAndRestoreClicked: () -> Unit = {}, + onEnqueueRemoteBackupClicked: () -> Unit = {}, + onWipeDataAndRestoreFromRemoteClicked: () -> Unit = {}, onBackupTierSelected: (MessageBackupTier?) -> Unit = {}, - onHaltAllJobs: () -> Unit = {}, - onSavePlaintextCopy: () -> Unit = {} + onHaltAllBackupJobsClicked: () -> Unit = {}, + onSavePlaintextCopyOfRemoteBackupClicked: () -> Unit = {}, + onValidateBackupClicked: () -> Unit = {}, + onSaveEncryptedBackupToDiskClicked: () -> Unit = {}, + onSavePlaintextBackupToDiskClicked: () -> Unit = {}, + onImportEncryptedBackupFromDiskClicked: () -> Unit = {} ) { val scrollState = rememberScrollState() val options = remember { @@ -322,7 +315,6 @@ fun Screen( modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) - .padding(16.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Tier", fontWeight = FontWeight.Bold) @@ -339,191 +331,121 @@ fun Screen( Dividers.Default() - Buttons.LargePrimary( - onClick = onTriggerBackupJobClicked - ) { - Text("Enqueue remote backup") - } - - Button( - onClick = onWipeDataAndRestoreClicked, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFC33C00)) - ) { - Text("Wipe data and restore") - } - - Buttons.LargeTonal( - onClick = onHaltAllJobs - ) { - Text("Halt all backup jobs") - } - - Buttons.LargeTonal( - onClick = onSavePlaintextCopy - ) { - Text("Save plaintext copy of remote backup") - } + Rows.TextRow( + text = { + Text( + text = state.statusMessage ?: "Status messages will appear here as you perform operations", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) Dividers.Default() - Buttons.LargeTonal( + Rows.TextRow( + text = "Check remote backup state", + label = "Get a summary of what your remote backup looks like (presence, size, etc).", + onClick = onCheckRemoteBackupStateClicked + ) + + Rows.TextRow( + text = "Enqueue remote backup", + label = "Schedules a job that will perform a routine remote backup.", + onClick = onEnqueueRemoteBackupClicked + ) + + Rows.TextRow( + text = "Halt all backup jobs", + label = "Stops all backup-related jobs to the best of our ability.", + onClick = onHaltAllBackupJobsClicked + ) + + Rows.TextRow( + text = "Validate backup", + label = "Generates a new backup and reports whether it passes validation. Does not save or upload anything.", + onClick = onValidateBackupClicked + ) + + Rows.TextRow( + text = "Save encrypted backup to disk", + label = "Generates an encrypted backup (the same thing you would upload) and saves it to your local disk.", + onClick = onSaveEncryptedBackupToDiskClicked + ) + + Rows.TextRow( + text = "Save plaintext backup to disk", + label = "Generates a plaintext, uncompressed backup and saves it to your local disk.", + onClick = onSavePlaintextBackupToDiskClicked + ) + + Rows.TextRow( + text = "Save plaintext copy of remote backup", + label = "Downloads your most recently uploaded backup and saves it to disk, plaintext and uncompressed.", + onClick = onSavePlaintextCopyOfRemoteBackupClicked + ) + + Rows.TextRow( + text = "Perform a new-style local backup", + label = "Creates a local backup (in your already-chosen backup directory) using the new on-disk backup format. This is the successor to the local backups existing users can build.", + onClick = onExportNewStyleLocalBackupClicked + ) + + Dividers.Default() + + Text( + text = "DANGER ZONE", + color = Color.Red, + fontWeight = FontWeight.Bold + ) + Rows.TextRow( + text = { + Text( + text = "The following operations are potentially destructive! Only use them if you know what you're doing.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + ) + + Rows.TextRow( + text = "Wipe data and restore from remote", + label = "Erases all content on your device, followed by a restore of your remote backup.", + onClick = onWipeDataAndRestoreFromRemoteClicked + ) + + Rows.TextRow( + text = "Clear backup init flag", + label = "Clears our local state around whether backups have been initialized or not. Will force us to make request to claim backupId and set public keys.", onClick = { SignalStore.backup.backupsInitialized = false } - ) { - Text("Clear backup init flag") - } + ) - Buttons.LargeTonal( + Rows.TextRow( + text = "Clear backup credentials", + label = "Clears any cached backup credentials, for both our message and media backups.", onClick = { SignalStore.backup.messageCredentials.clearAll() SignalStore.backup.mediaCredentials.clearAll() } - ) { - Text("Clear backup credentials") - } + ) + + Rows.TextRow( + text = "Wipe all data and restore from file", + label = "Erases all content on your device, followed by a restore of an encrypted backup selected from disk.", + onClick = onImportEncryptedBackupFromDiskClicked + ) + + Rows.TextRow( + text = "Wipe all data and restore a new-style local backup", + label = "Erases all content on your device, followed by a restore of a previously-generated new-style local backup.", + onClick = onImportNewStyleLocalBackupClicked + ) Dividers.Default() - Row( - verticalAlignment = Alignment.CenterVertically - ) { - StateLabel(text = "Plaintext?") - Spacer(modifier = Modifier.width(8.dp)) - Switch( - checked = state.plaintext, - onCheckedChange = { onPlaintextClicked() } - ) - } - Spacer(modifier = Modifier.height(8.dp)) - - Buttons.LargePrimary( - onClick = onExportClicked, - enabled = !state.backupState.inProgress - ) { - Text("Export") - } - - Buttons.LargePrimary( - onClick = onExportDirectoryClicked, - enabled = !state.backupState.inProgress && state.canReadWriteBackupDirectory - ) { - Text("Export to backup directory") - } - - Dividers.Default() - - Buttons.LargeTonal( - onClick = onImportMemoryClicked, - enabled = state.backupState == BackupState.EXPORT_DONE - ) { - Text("Import from memory") - } - Buttons.LargeTonal( - onClick = onImportFileClicked - ) { - Text("Import from file") - } - Buttons.LargeTonal( - onClick = onImportDirectoryClicked, - enabled = state.canReadWriteBackupDirectory - ) { - Text("Import from backup directory") - } - - Buttons.LargeTonal( - onClick = onValidateFileClicked - ) { - Text("Validate file") - } - - Spacer(modifier = Modifier.height(16.dp)) - - when (state.backupState) { - BackupState.NONE -> { - StateLabel("") - } - - BackupState.EXPORT_IN_PROGRESS -> { - StateLabel("Export in progress...") - } - - BackupState.EXPORT_DONE -> { - StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, save it to a file, or upload it to remote.") - - Spacer(modifier = Modifier.height(8.dp)) - - Buttons.MediumTonal(onClick = onSaveToDiskClicked) { - Text("Save to file") - } - } - - BackupState.BACKUP_JOB_DONE -> { - StateLabel("Backup complete and uploaded") - } - - BackupState.IMPORT_IN_PROGRESS -> { - StateLabel("Import in progress...") - } - } - - Dividers.Default() - - Buttons.LargeTonal( - onClick = onCheckRemoteBackupStateClicked - ) { - Text("Check remote backup state") - } - - Spacer(modifier = Modifier.height(8.dp)) - - when (state.remoteBackupState) { - is InternalBackupPlaygroundViewModel.RemoteBackupState.Available -> { - StateLabel("Exists/allocated. ${state.remoteBackupState.response.mediaCount} media items, using ${state.remoteBackupState.response.usedSpace} bytes (${state.remoteBackupState.response.usedSpace.bytes.inMebiBytes.roundedString(3)} MiB)") - } - - InternalBackupPlaygroundViewModel.RemoteBackupState.GeneralError -> { - StateLabel("Hit an unknown error. Check the logs.") - } - - InternalBackupPlaygroundViewModel.RemoteBackupState.NotFound -> { - StateLabel("Not found.") - } - - InternalBackupPlaygroundViewModel.RemoteBackupState.Unknown -> { - StateLabel("Hit the button above to check the state.") - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Buttons.LargePrimary( - onClick = onUploadToRemoteClicked, - enabled = state.backupState == BackupState.EXPORT_DONE - ) { - Text("Upload to remote") - } - - Spacer(modifier = Modifier.height(8.dp)) - - when (state.uploadState) { - BackupUploadState.NONE -> { - StateLabel("") - } - - BackupUploadState.UPLOAD_IN_PROGRESS -> { - StateLabel("Upload in progress...") - } - - BackupUploadState.UPLOAD_DONE -> { - StateLabel("Upload complete.") - } - - BackupUploadState.UPLOAD_FAILED -> { - StateLabel("Upload failed.") - } - } } } } @@ -713,57 +635,18 @@ private data class MediaMultiSelectState( val expandedOption: AttachmentId? = null ) -@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) +@SignalPreview @Composable fun PreviewScreen() { - SignalTheme { - Surface { - Screen(state = ScreenState(backupState = BackupState.NONE, plaintext = false)) - } + Previews.Preview { + Screen(state = ScreenState()) } } -@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) +@SignalPreview @Composable fun PreviewScreenExportInProgress() { - SignalTheme { - Surface { - Screen(state = ScreenState(backupState = BackupState.EXPORT_IN_PROGRESS, plaintext = false)) - } - } -} - -@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun PreviewScreenExportDone() { - SignalTheme { - Surface { - Screen(state = ScreenState(backupState = BackupState.EXPORT_DONE, plaintext = false)) - } - } -} - -@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun PreviewScreenImportInProgress() { - SignalTheme { - Surface { - Screen(state = ScreenState(backupState = BackupState.IMPORT_IN_PROGRESS, plaintext = false)) - } - } -} - -@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -fun PreviewScreenUploadInProgress() { - SignalTheme { - Surface { - Screen(state = ScreenState(uploadState = BackupUploadState.UPLOAD_IN_PROGRESS, plaintext = false)) - } + Previews.Preview { + Screen(state = ScreenState(statusMessage = "Some random status message.")) } } 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 afc271097b..c9ee344f4e 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 @@ -18,14 +18,17 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.bytes import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.copyTo import org.signal.core.util.logging.Log import org.signal.core.util.readNBytesOrThrow +import org.signal.core.util.roundedString import org.signal.core.util.stream.LimitedInputStream import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.backup.v2.ArchiveValidator import org.thoughtcrime.securesms.backup.v2.BackupMetadata import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.MessageBackupTier @@ -54,7 +57,7 @@ import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.backup.MediaName -import java.io.ByteArrayInputStream +import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream @@ -72,15 +75,10 @@ class InternalBackupPlaygroundViewModel : ViewModel() { private val TAG = Log.tag(InternalBackupPlaygroundViewModel::class) } - var backupData: ByteArray? = null - val disposables = CompositeDisposable() private val _state: MutableState = mutableStateOf( ScreenState( - backupState = BackupState.NONE, - uploadState = BackupUploadState.NONE, - plaintext = false, canReadWriteBackupDirectory = SignalStore.settings.signalBackupDirectory?.let { val file = DocumentFile.fromTreeUri(AppDependencies.application, it) file != null && file.canWrite() && file.canRead() @@ -93,66 +91,94 @@ class InternalBackupPlaygroundViewModel : ViewModel() { private val _mediaState: MutableState = mutableStateOf(MediaState()) val mediaState: State = _mediaState - fun export() { - _state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS) - val plaintext = _state.value.plaintext - - disposables += Single.fromCallable { BackupRepository.debugExport(plaintext = plaintext) } + fun exportEncrypted(openStream: () -> OutputStream, appendStream: () -> OutputStream) { + _state.value = _state.value.copy(statusMessage = "Exporting encrypted backup to disk...") + disposables += Single + .fromCallable { + BackupRepository.export( + outputStream = openStream(), + append = { bytes -> appendStream().use { it.write(bytes) } } + ) + } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe { data -> - backupData = data - _state.value = _state.value.copy(backupState = BackupState.EXPORT_DONE) + _state.value = _state.value.copy(statusMessage = "Encrypted backup complete!") + } + } + + fun exportPlaintext(openStream: () -> OutputStream, appendStream: () -> OutputStream) { + _state.value = _state.value.copy(statusMessage = "Exporting plaintext backup to disk...") + disposables += Single + .fromCallable { + BackupRepository.export( + outputStream = openStream(), + append = { bytes -> appendStream().use { it.write(bytes) } }, + plaintext = true + ) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { data -> + _state.value = _state.value.copy(statusMessage = "Plaintext backup complete!") + } + } + + fun validateBackup() { + _state.value = _state.value.copy(statusMessage = "Exporting to a temporary file...") + val tempFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) + + disposables += Single + .fromCallable { + BackupRepository.export( + outputStream = FileOutputStream(tempFile), + append = { bytes -> tempFile.appendBytes(bytes) } + ) + _state.value = _state.value.copy(statusMessage = "Export complete! Validating...") + ArchiveValidator.validate(tempFile, SignalStore.backup.messageBackupKey) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { result -> + val message = when (result) { + is ArchiveValidator.ValidationResult.ReadError -> "Failed to read backup file!" + ArchiveValidator.ValidationResult.Success -> "Validation passed!" + is ArchiveValidator.ValidationResult.ValidationError -> { + Log.w(TAG, "Validation failed!", result.exception) + "Validation failed :( Check the logs for details." + } + } + _state.value = _state.value.copy(statusMessage = message) } } fun triggerBackupJob() { - _state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS) + _state.value = _state.value.copy(statusMessage = "Upload job in progress...") disposables += Single.fromCallable { AppDependencies.jobManager.runSynchronously(BackupMessagesJob(), 120_000) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy { - _state.value = _state.value.copy(backupState = BackupState.BACKUP_JOB_DONE) + .subscribeBy { result -> + _state.value = _state.value.copy(statusMessage = "Upload job complete! Result: ${result.takeIf { it.isPresent }?.get() ?: "N/A"}") } } - fun import() { - backupData?.let { - _state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS) - val plaintext = _state.value.plaintext - - val self = Recipient.self() - val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey)) - - disposables += Single.fromCallable { BackupRepository.import(it.size.toLong(), { ByteArrayInputStream(it) }, selfData, plaintext = plaintext) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy { - backupData = null - _state.value = _state.value.copy(backupState = BackupState.NONE) - } - } - } - - fun import(length: Long, inputStreamFactory: () -> InputStream) { - _state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS) - val plaintext = _state.value.plaintext + fun importEncryptedBackup(length: Long, inputStreamFactory: () -> InputStream) { + _state.value = _state.value.copy(statusMessage = "Importing encrypted backup...") val self = Recipient.self() val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey)) - disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = plaintext) } + disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = false) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy { - backupData = null - _state.value = _state.value.copy(backupState = BackupState.NONE) + _state.value = _state.value.copy(statusMessage = "Encrypted backup import complete!") } } fun import(uri: Uri) { - _state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS) + _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)) @@ -170,42 +196,59 @@ class InternalBackupPlaygroundViewModel : ViewModel() { .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy { - backupData = null - _state.value = _state.value.copy(backupState = BackupState.NONE) + _state.value = _state.value.copy(statusMessage = "New-style local backup import complete!") } } - 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()) - .subscribeBy { - backupData = null - _state.value = _state.value.copy(backupState = BackupState.NONE) - } + fun haltAllJobs() { + AppDependencies.jobManager.cancelAllInQueue(BackfillDigestJob.QUEUE) + AppDependencies.jobManager.cancelAllInQueue("ArchiveAttachmentJobs_0") + AppDependencies.jobManager.cancelAllInQueue("ArchiveAttachmentJobs_1") + AppDependencies.jobManager.cancelAllInQueue("ArchiveThumbnailUploadJob") + AppDependencies.jobManager.cancelAllInQueue("BackupRestoreJob") + AppDependencies.jobManager.cancelAllInQueue("__LOCAL_BACKUP__") } - fun onPlaintextToggled() { - _state.value = _state.value.copy(plaintext = !_state.value.plaintext) - } + fun fetchRemoteBackupAndWritePlaintext(outputStream: OutputStream?) { + check(outputStream != null) - fun uploadBackupToRemote() { - _state.value = _state.value.copy(uploadState = BackupUploadState.UPLOAD_IN_PROGRESS) + SignalExecutors.BOUNDED_IO.execute { + Log.d(TAG, "Downloading file...") + val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) - disposables += Single - .fromCallable { BackupRepository.debugUploadBackupFile(backupData!!.inputStream(), backupData!!.size.toLong()) is NetworkResult.Success } - .subscribeOn(Schedulers.io()) - .subscribe { success -> - _state.value = _state.value.copy(uploadState = if (success) BackupUploadState.UPLOAD_DONE else BackupUploadState.UPLOAD_FAILED) + when (val result = BackupRepository.downloadBackupFile(tempBackupFile)) { + is NetworkResult.Success -> Log.i(TAG, "Download successful") + else -> { + Log.w(TAG, "Failed to download backup file", result.getCause()) + throw IOException(result.getCause()) + } } + + val encryptedStream = tempBackupFile.inputStream() + val iv = encryptedStream.readNBytesOrThrow(16) + val backupKey = SignalStore.backup.messageBackupKey + val keyMaterial = backupKey.deriveBackupSecrets(Recipient.self().aci.get()) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { + init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.aesKey, "AES"), IvParameterSpec(iv)) + } + + val plaintextStream = GZIPInputStream( + CipherInputStream( + LimitedInputStream( + wrapped = encryptedStream, + maxBytes = tempBackupFile.length() - MAC_SIZE + ), + cipher + ) + ) + + Log.d(TAG, "Copying...") + plaintextStream.copyTo(outputStream) + Log.d(TAG, "Done!") + } } fun checkRemoteBackupState() { - _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Unknown) - disposables += Single .fromCallable { BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) @@ -215,16 +258,18 @@ class InternalBackupPlaygroundViewModel : ViewModel() { .subscribe { result -> when { result is NetworkResult.Success -> { - _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Available(result.result)) + _state.value = _state.value.copy( + statusMessage = "Remote backup exists. ${result.result.mediaCount} media items, using ${result.result.usedSpace} bytes (${result.result.usedSpace.bytes.inMebiBytes.roundedString(3)} MiB)" + ) } result is NetworkResult.StatusCodeError && result.code == 404 -> { - _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.NotFound) + _state.value = _state.value.copy(statusMessage = "Remote backup does not exists.") } else -> { Log.w(TAG, "Error checking remote backup state", result.getCause()) - _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.GeneralError) + _state.value = _state.value.copy(statusMessage = "Failed to fetch remote backup state.") } } } @@ -242,7 +287,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() { } private fun restoreFromRemote() { - _state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS) + _state.value = _state.value.copy(statusMessage = "Importing from remote...") disposables += Single.fromCallable { AppDependencies @@ -255,7 +300,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() { .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy { - _state.value = _state.value.copy(backupState = BackupState.NONE) + _state.value = _state.value.copy(statusMessage = "Import complete!") } } @@ -437,33 +482,21 @@ class InternalBackupPlaygroundViewModel : ViewModel() { } data class ScreenState( - val backupState: BackupState = BackupState.NONE, - val uploadState: BackupUploadState = BackupUploadState.NONE, - val remoteBackupState: RemoteBackupState = RemoteBackupState.Unknown, - val plaintext: Boolean, val canReadWriteBackupDirectory: Boolean = false, - val backupTier: MessageBackupTier? = null + val backupTier: MessageBackupTier? = null, + val statusMessage: String? = null ) enum class BackupState(val inProgress: Boolean = false) { NONE, EXPORT_IN_PROGRESS(true), EXPORT_DONE, - BACKUP_JOB_DONE, IMPORT_IN_PROGRESS(true) } - enum class BackupUploadState(val inProgress: Boolean = false) { - NONE, - UPLOAD_IN_PROGRESS(true), - UPLOAD_DONE, - UPLOAD_FAILED - } - sealed class RemoteBackupState { object Unknown : RemoteBackupState() object NotFound : RemoteBackupState() - object GeneralError : RemoteBackupState() data class Available(val response: BackupMetadata) : RemoteBackupState() } @@ -532,52 +565,4 @@ class InternalBackupPlaygroundViewModel : ViewModel() { fun MutableState.set(update: T.() -> T) { this.value = this.value.update() } - - fun haltAllJobs() { - AppDependencies.jobManager.cancelAllInQueue(BackfillDigestJob.QUEUE) - AppDependencies.jobManager.cancelAllInQueue("ArchiveAttachmentJobs_0") - AppDependencies.jobManager.cancelAllInQueue("ArchiveAttachmentJobs_1") - AppDependencies.jobManager.cancelAllInQueue("ArchiveThumbnailUploadJob") - AppDependencies.jobManager.cancelAllInQueue("BackupRestoreJob") - AppDependencies.jobManager.cancelAllInQueue("__LOCAL_BACKUP__") - } - - fun fetchRemoteBackupAndWritePlaintext(outputStream: OutputStream?) { - check(outputStream != null) - - SignalExecutors.BOUNDED_IO.execute { - Log.d(TAG, "Downloading file...") - val tempBackupFile = BlobProvider.getInstance().forNonAutoEncryptingSingleSessionOnDisk(AppDependencies.application) - - when (val result = BackupRepository.downloadBackupFile(tempBackupFile)) { - is NetworkResult.Success -> Log.i(TAG, "Download successful") - else -> { - Log.w(TAG, "Failed to download backup file", result.getCause()) - throw IOException(result.getCause()) - } - } - - val encryptedStream = tempBackupFile.inputStream() - val iv = encryptedStream.readNBytesOrThrow(16) - val backupKey = SignalStore.backup.messageBackupKey - val keyMaterial = backupKey.deriveBackupSecrets(Recipient.self().aci.get()) - val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { - init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.aesKey, "AES"), IvParameterSpec(iv)) - } - - val plaintextStream = GZIPInputStream( - CipherInputStream( - LimitedInputStream( - wrapped = encryptedStream, - maxBytes = tempBackupFile.length() - MAC_SIZE - ), - cipher - ) - ) - - Log.d(TAG, "Copying...") - plaintextStream.copyTo(outputStream) - Log.d(TAG, "Done!") - } - } }