Add underpinnings to allow for local plaintext export.

Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
Alex Hart
2026-03-25 16:44:26 -03:00
committed by Cody Henthorne
parent b605148ac4
commit f2e4881026
15 changed files with 859 additions and 42 deletions

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -71,9 +72,11 @@ import org.signal.core.util.Hex
import org.signal.core.util.Util
import org.signal.core.util.getLength
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.backup.isIdle
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert
import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.DialogState
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState
import org.thoughtcrime.securesms.dependencies.AppDependencies
@@ -111,10 +114,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
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")!! }
)
viewModel.exportPlaintextZip(uri)
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
}
}
@@ -173,15 +173,21 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
saveEncryptedBackupToDiskLauncher.launch(intent)
},
onSavePlaintextBackupToDiskClicked = {
val intent = Intent().apply {
action = Intent.ACTION_CREATE_DOCUMENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "backup-plaintext-${System.currentTimeMillis()}.bin")
}
viewModel.showPlaintextExportDialog()
},
onPlaintextExportWithMedia = {
viewModel.onPlaintextExportMediaChoiceSelected(includeMedia = true)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
savePlaintextBackupToDiskLauncher.launch(intent)
},
onPlaintextExportWithoutMedia = {
viewModel.onPlaintextExportMediaChoiceSelected(includeMedia = false)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
savePlaintextBackupToDiskLauncher.launch(intent)
},
onPlaintextExportDismissed = {
viewModel.dismissPlaintextExportDialog()
},
onSavePlaintextCopyOfRemoteBackupClicked = {
val intent = Intent().apply {
action = Intent.ACTION_CREATE_DOCUMENT
@@ -352,6 +358,9 @@ fun Screen(
onValidateBackupClicked: () -> Unit = {},
onSaveEncryptedBackupToDiskClicked: () -> Unit = {},
onSavePlaintextBackupToDiskClicked: () -> Unit = {},
onPlaintextExportWithMedia: () -> Unit = {},
onPlaintextExportWithoutMedia: () -> Unit = {},
onPlaintextExportDismissed: () -> Unit = {},
onImportEncryptedBackupFromDiskClicked: () -> Unit = {},
onImportEncryptedBackupFromDiskDismissed: () -> Unit = {},
onImportEncryptedBackupFromDiskConfirmed: (aci: String, backupKey: String) -> Unit = { _, _ -> },
@@ -370,6 +379,19 @@ fun Screen(
onDismissed = onImportEncryptedBackupFromDiskDismissed
)
}
DialogState.PlaintextExportMediaChoice -> {
AlertDialog(
onDismissRequest = onPlaintextExportDismissed,
title = { Text("Plaintext backup") },
text = { Text("Include media in the backup?") },
confirmButton = {
TextButton(onClick = onPlaintextExportWithMedia) { Text("With media") }
},
dismissButton = {
TextButton(onClick = onPlaintextExportWithoutMedia) { Text("Without media") }
}
)
}
}
Surface {
@@ -446,11 +468,18 @@ fun Screen(
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
)
if (state.plaintextProgress.isIdle) {
Rows.TextRow(
text = "Save plaintext backup to disk",
label = "Generates a plaintext backup as a zip file in the selected directory.",
onClick = onSavePlaintextBackupToDiskClicked
)
} else {
BackupCreationProgressRow(
progress = state.plaintextProgress,
isRemote = false
)
}
Rows.TextRow(
text = "Save plaintext copy of remote backup",
@@ -602,7 +631,7 @@ private fun ImportCredentialsDialog(onSubmit: (aci: String, backupKey: String) -
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next
)
androidx.compose.material3.AlertDialog(
AlertDialog(
onDismissRequest = onDismissed,
title = { Text(text = "Are you sure?") },
text = {

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.settings.app.internal.backup
import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
@@ -24,7 +25,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.archive.stream.EncryptedBackupReader
import org.signal.archive.stream.EncryptedBackupReader.Companion.MAC_SIZE
import org.signal.core.models.ServiceId.ACI
import org.signal.core.models.ServiceId
import org.signal.core.models.backup.MessageBackupKey
import org.signal.core.util.Hex
import org.signal.core.util.ThreadUtil
@@ -49,7 +50,9 @@ import org.thoughtcrime.securesms.database.AttachmentTable.DebugAttachmentStats
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
@@ -87,9 +90,12 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val statsState: MutableStateFlow<StatsState> = MutableStateFlow(StatsState())
enum class DialogState {
None,
ImportCredentials
init {
viewModelScope.launch {
SignalStore.backup.newLocalPlaintextBackupProgressFlow.collect { progress ->
_state.value = _state.value.copy(plaintextProgress = progress)
}
}
}
fun exportEncrypted(openStream: () -> OutputStream, appendStream: () -> OutputStream) {
@@ -108,21 +114,23 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
fun exportPlaintext(openStream: () -> OutputStream, appendStream: () -> OutputStream) {
_state.value = _state.value.copy(statusMessage = "Exporting plaintext backup to disk...")
disposables += Single
.fromCallable {
BackupRepository.exportForDebugging(
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!")
}
private var plaintextIncludeMedia: Boolean = false
fun showPlaintextExportDialog() {
_state.value = _state.value.copy(dialog = DialogState.PlaintextExportMediaChoice)
}
fun dismissPlaintextExportDialog() {
_state.value = _state.value.copy(dialog = DialogState.None)
}
fun onPlaintextExportMediaChoiceSelected(includeMedia: Boolean) {
plaintextIncludeMedia = includeMedia
_state.value = _state.value.copy(dialog = DialogState.None)
}
fun exportPlaintextZip(directoryUri: Uri) {
LocalBackupJob.enqueuePlaintextArchive(directoryUri.toString(), plaintextIncludeMedia)
}
fun validateBackup() {
@@ -289,7 +297,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
/** True if data is valid, else false */
fun onImportConfirmed(aci: String, backupKey: String): Boolean {
val parsedAci: ACI? = ACI.parseOrNull(aci)
val parsedAci: ServiceId.ACI? = ServiceId.ACI.parseOrNull(aci)
if (aci.isNotBlank() && parsedAci == null) {
_state.value = _state.value.copy(statusMessage = "Invalid ACI! Cannot import.")
@@ -332,6 +340,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
_state.value = _state.value.copy(statusMessage = "Import complete!")
ThreadUtil.runOnMain { afterDbRestoreCallback() }
}
RemoteRestoreResult.Canceled,
RemoteRestoreResult.Failure,
RemoteRestoreResult.PermanentSvrBFailure,
@@ -382,6 +391,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
SignalStore.backup.cachedMediaCdnPath = null
return@withContext true
}
else -> Log.w(TAG, "Unable to delete remote data", result.getCause())
}
@@ -400,6 +410,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
val canReadWriteBackupDirectory: Boolean = false,
val backupTier: MessageBackupTier? = null,
val statusMessage: String? = null,
val plaintextProgress: LocalBackupCreationProgress = LocalBackupCreationProgress(),
val customBackupCredentials: ImportCredentials? = null,
val dialog: DialogState = DialogState.None
)
@@ -470,7 +481,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
data class ImportCredentials(
val messageBackupKey: MessageBackupKey,
val aci: ACI
val aci: ServiceId.ACI
)
data class StatsState(
@@ -481,4 +492,10 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
) {
val valid = attachmentStats != null
}
enum class DialogState {
None,
ImportCredentials,
PlaintextExportMediaChoice
}
}