Update the backup playground to be more friendly.

This commit is contained in:
Greyson Parrelli 2024-12-09 15:56:20 -05:00
parent d1bfa6ee9e
commit 3ea9dd5e1d
4 changed files with 298 additions and 455 deletions

View file

@ -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())

View file

@ -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<ArchiveGetMediaItemsResponse> {
return initBackupAndFetchAuth()
.then { credential ->

View file

@ -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<Intent>
private lateinit var importFileLauncher: ActivityResultLauncher<Intent>
private lateinit var validateFileLauncher: ActivityResultLauncher<Intent>
private lateinit var savePlaintextcopyLauncher: ActivityResultLauncher<Intent>
private lateinit var saveEncryptedBackupToDiskLauncher: ActivityResultLauncher<Intent>
private lateinit var savePlaintextBackupToDiskLauncher: ActivityResultLauncher<Intent>
private lateinit var importEncryptedBackupFromDiskLauncher: ActivityResultLauncher<Intent>
private lateinit var savePlaintextCopyLauncher: ActivityResultLauncher<Intent>
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."))
}
}

View file

@ -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<ScreenState> = 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<MediaState> = mutableStateOf(MediaState())
val mediaState: State<MediaState> = _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 <T> MutableState<T>.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!")
}
}
}