Add plaintext chat history export UI.

This commit is contained in:
Alex Hart
2026-03-31 12:55:52 -03:00
parent 0ce3eab3cd
commit 3f067654d9
12 changed files with 504 additions and 18 deletions

View File

@@ -8,7 +8,7 @@ package org.thoughtcrime.securesms.backup
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
val LocalBackupCreationProgress.isIdle: Boolean
get() = idle != null || (exporting == null && transferring == null && canceled == null)
get() = idle != null || succeeded != null || failed != null || canceled != null || (exporting == null && transferring == null)
fun LocalBackupCreationProgress.exportProgress(): Float {
val exporting = exporting ?: return 0f

View File

@@ -10,6 +10,8 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.exportProgress
import org.thoughtcrime.securesms.backup.transferProgress
@@ -32,7 +35,8 @@ import org.signal.core.ui.R as CoreUiR
fun BackupCreationProgressRow(
progress: LocalBackupCreationProgress,
isRemote: Boolean,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
onCancel: (() -> Unit)? = null
) {
Row(
modifier = modifier
@@ -42,7 +46,7 @@ fun BackupCreationProgressRow(
Column(
modifier = Modifier.weight(1f)
) {
BackupCreationProgressIndicator(progress = progress)
BackupCreationProgressIndicator(progress = progress, onCancel = onCancel)
Text(
text = getProgressMessage(progress, isRemote),
@@ -55,7 +59,8 @@ fun BackupCreationProgressRow(
@Composable
private fun BackupCreationProgressIndicator(
progress: LocalBackupCreationProgress
progress: LocalBackupCreationProgress,
onCancel: (() -> Unit)? = null
) {
val exporting = progress.exporting
val transferring = progress.transferring
@@ -93,6 +98,15 @@ private fun BackupCreationProgressIndicator(
.padding(vertical = 12.dp)
)
}
if (onCancel != null) {
IconButton(onClick = onCancel) {
Icon(
imageVector = SignalIcons.X.imageVector,
contentDescription = "Cancel"
)
}
}
}
}
@@ -224,7 +238,8 @@ private fun TransferringRemotePreview() {
mediaPhase = true
)
),
isRemote = true
isRemote = true,
onCancel = {}
)
}
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Launchers
import org.thoughtcrime.securesms.R
/**
* Dialogs displayed while processing a user's decrypted chat export.
*
* Displayed *after* the user has confirmed via phone auth.
*/
@Composable
fun ChatExportDialogs(state: ChatExportState, callbacks: ChatExportCallbacks) {
val folderPicker = Launchers.rememberOpenDocumentTreeLauncher {
if (it != null) {
callbacks.onFolderSelected(it)
} else {
callbacks.onCancelStartExport()
}
}
when (state) {
ChatExportState.None -> Unit
ChatExportState.ConfirmExport -> ConfirmExportDialog(
onConfirmExport = callbacks::onConfirmExport,
onCancel = callbacks::onCancelStartExport
)
ChatExportState.ChooseAFolder -> ChooseAFolderDialog(
onChooseAFolder = { folderPicker.launch(null) },
onCancel = callbacks::onCancelStartExport
)
ChatExportState.Canceling -> Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.ChatExportDialogs__canceling_export))
ChatExportState.Success -> CompleteDialog(
onOK = callbacks::onCompletionConfirmed
)
}
}
@Composable
private fun ConfirmExportDialog(
onConfirmExport: (withMedia: Boolean) -> Unit,
onCancel: () -> Unit
) {
val body = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.ChatExportDialogs__be_careful_warning))
}
append(" ")
append(stringResource(R.string.ChatExportDialogs__export_confirm_body))
}
Dialogs.AdvancedAlertDialog(
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_chat_history_title)),
body = body,
positive = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_with_media)),
neutral = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_without_media)),
negative = AnnotatedString(stringResource(android.R.string.cancel)),
onPositive = { onConfirmExport(true) },
onNeutral = { onConfirmExport(false) },
onNegative = onCancel
)
}
@Composable
private fun ChooseAFolderDialog(
onChooseAFolder: () -> Unit,
onCancel: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.ChatExportDialogs__choose_a_folder_title),
body = stringResource(R.string.ChatExportDialogs__choose_a_folder_body),
confirm = stringResource(R.string.ChatExportDialogs__choose_folder_button),
dismiss = stringResource(android.R.string.cancel),
onConfirm = onChooseAFolder,
onDeny = onCancel
)
}
@Composable
private fun CompleteDialog(
onOK: () -> Unit
) {
val body = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.ChatExportDialogs__be_careful))
}
append(" ")
append(stringResource(R.string.ChatExportDialogs__complete_body))
}
Dialogs.SimpleAlertDialog(
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__complete_title)),
body = body,
confirm = AnnotatedString(stringResource(android.R.string.ok)),
onConfirm = onOK
)
}
enum class ChatExportState {
None,
ConfirmExport,
ChooseAFolder,
Canceling,
Success
}
interface ChatExportCallbacks {
fun onConfirmExport(withMedia: Boolean)
fun onFolderSelected(uri: Uri)
fun onCancelStartExport()
fun onCompletionConfirmed()
object Empty : ChatExportCallbacks {
override fun onConfirmExport(withMedia: Boolean) = Unit
override fun onFolderSelected(uri: Uri) = Unit
override fun onCancelStartExport() = Unit
override fun onCompletionConfirmed() = Unit
}
}

View File

@@ -1,16 +1,20 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers
@@ -18,9 +22,14 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.isIdle
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
@@ -79,10 +88,38 @@ class ChatsSettingsFragment : ComposeFragment() {
override fun onEnterKeySendsChanged(enabled: Boolean) {
viewModel.setEnterKeySends(enabled)
}
override fun onExportPlaintextChatHistoryClick() {
viewModel.requestChatExportType()
}
override fun onCancelInFlightExport() {
viewModel.cancelChatExport()
}
// region ChatExportCallback
override fun onConfirmExport(withMedia: Boolean) {
viewModel.setExportTypeAndGoToSelectFolder(withMedia)
}
override fun onFolderSelected(uri: Uri) {
viewModel.startChatExportToFolder(uri)
}
override fun onCancelStartExport() {
viewModel.clearChatExportFlow()
}
override fun onCompletionConfirmed() {
viewModel.clearChatExportFlow()
}
// endregion
}
}
private interface ChatsSettingsCallbacks {
private interface ChatsSettingsCallbacks : ChatExportCallbacks {
fun onNavigationClick() = Unit
fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit
fun onUseAddressBookChanged(enabled: Boolean) = Unit
@@ -91,8 +128,10 @@ private interface ChatsSettingsCallbacks {
fun onAddOrEditFoldersClick() = Unit
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
fun onExportPlaintextChatHistoryClick() = Unit
fun onCancelInFlightExport() = Unit
object Empty : ChatsSettingsCallbacks
object Empty : ChatsSettingsCallbacks, ChatExportCallbacks by ChatExportCallbacks.Empty
}
@Composable
@@ -100,10 +139,25 @@ private fun ChatsSettingsScreen(
state: ChatsSettingsState,
callbacks: ChatsSettingsCallbacks
) {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val authenticationFailedMessage = stringResource(R.string.ChatsSettingsFragment__authentication_failed)
val plaintextBiometricsAuthentication = rememberBiometricsAuthentication(
promptTitle = stringResource(R.string.ChatsSettingsFragment__unlock_to_export_chat_history),
onAuthenticationFailed = {
coroutineScope.launch {
snackbarHostState.showSnackbar(authenticationFailedMessage)
}
}
)
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__chats),
onNavigationClick = callbacks::onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector
navigationIcon = SignalIcons.ArrowStart.imageVector,
snackbarHost = {
Snackbars.Host(snackbarHostState)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
@@ -167,6 +221,36 @@ private fun ChatsSettingsScreen(
}
}
if (state.isPlaintextExportEnabled) {
item {
Dividers.Default()
}
if (state.plaintextExportProgress.isIdle) {
item(key = "export_chat_history_row") {
Rows.TextRow(
modifier = Modifier.animateItem(),
text = stringResource(R.string.ChatsSettingsFragment__export_chat_history),
label = stringResource(R.string.ChatsSettingsFragment__export_chat_history_label),
onClick = {
plaintextBiometricsAuthentication.withBiometricsAuthentication {
callbacks.onExportPlaintextChatHistoryClick()
}
}
)
}
} else {
item(key = "export_chat_history_progress") {
BackupCreationProgressRow(
modifier = Modifier.animateItem(),
progress = state.plaintextExportProgress,
isRemote = false,
onCancel = callbacks::onCancelInFlightExport
)
}
}
}
item {
Dividers.Default()
}
@@ -194,6 +278,13 @@ private fun ChatsSettingsScreen(
}
}
}
if (state.isPlaintextExportEnabled) {
ChatExportDialogs(
state = state.chatExportState,
callbacks = callbacks
)
}
}
@DayNightPreviews
@@ -210,7 +301,9 @@ private fun ChatsSettingsScreenPreview() {
localBackupsEnabled = true,
folderCount = 1,
userUnregistered = false,
clientDeprecated = false
clientDeprecated = false,
isPlaintextExportEnabled = true,
plaintextExportProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
),
callbacks = ChatsSettingsCallbacks.Empty
)

View File

@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
data class ChatsSettingsState(
val generateLinkPreviews: Boolean,
val useAddressBook: Boolean,
@@ -9,7 +12,11 @@ data class ChatsSettingsState(
val localBackupsEnabled: Boolean,
val folderCount: Int,
val userUnregistered: Boolean,
val clientDeprecated: Boolean
val clientDeprecated: Boolean,
val isPlaintextExportEnabled: Boolean,
val plaintextExportProgress: LocalBackupCreationProgress = SignalStore.backup.newLocalPlaintextBackupProgress,
val chatExportState: ChatExportState = ChatExportState.None,
val includeMediaInExport: Boolean = false
) {
fun isRegisteredAndUpToDate(): Boolean {
return !userUnregistered && !clientDeprecated

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -9,9 +10,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ThrottledDebouncer
@@ -31,12 +34,53 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application),
folderCount = 0,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered,
clientDeprecated = SignalStore.misc.isClientDeprecated
clientDeprecated = SignalStore.misc.isClientDeprecated,
isPlaintextExportEnabled = Environment.Backups.isLocalPlaintextBackupExportEnabled(),
chatExportState = ChatExportState.None
)
)
val state: StateFlow<ChatsSettingsState> = store
init {
viewModelScope.launch {
SignalStore.backup.newLocalPlaintextBackupProgressFlow.collect { progress ->
store.update {
it.copy(
plaintextExportProgress = progress,
chatExportState = when {
progress.succeeded != null && it.plaintextExportProgress.succeeded == null -> ChatExportState.Success
progress.canceled != null -> ChatExportState.None
else -> it.chatExportState
}
)
}
}
}
}
fun requestChatExportType() {
store.update { it.copy(chatExportState = ChatExportState.ConfirmExport) }
}
fun setExportTypeAndGoToSelectFolder(includeMediaInExport: Boolean) {
store.update { it.copy(chatExportState = ChatExportState.ChooseAFolder, includeMediaInExport = includeMediaInExport) }
}
fun startChatExportToFolder(uri: Uri) {
store.update { it.copy(chatExportState = ChatExportState.None) }
LocalBackupJob.enqueuePlaintextArchive(uri.toString(), store.value.includeMediaInExport)
}
fun clearChatExportFlow() {
store.update { it.copy(chatExportState = ChatExportState.None, includeMediaInExport = false) }
}
fun cancelChatExport() {
store.update { it.copy(chatExportState = ChatExportState.Canceling) }
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.PLAINTEXT_ARCHIVE_QUEUE)
}
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
store.update { it.copy(generateLinkPreviews = enabled) }
SignalStore.settings.isLinkPreviewsEnabled = enabled

View File

@@ -45,6 +45,7 @@ public final class LocalBackupJob extends BaseJob {
private static final String TAG = Log.tag(LocalBackupJob.class);
public static final String QUEUE = "__LOCAL_BACKUP__";
public static final String PLAINTEXT_ARCHIVE_QUEUE = "__LOCAL_PLAINTEXT_ARCHIVE__";
public static final String TEMP_BACKUP_FILE_PREFIX = ".backup";
public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp";
@@ -80,7 +81,7 @@ public final class LocalBackupJob extends BaseJob {
public static void enqueuePlaintextArchive(String destinationUri, boolean includeMedia) {
JobManager jobManager = AppDependencies.getJobManager();
Parameters.Builder parameters = new Parameters.Builder()
.setQueue(QUEUE)
.setQueue(PLAINTEXT_ARCHIVE_QUEUE)
.setMaxInstancesForFactory(1)
.setMaxAttempts(3);

View File

@@ -41,6 +41,8 @@ class LocalPlaintextArchiveJob internal constructor(
private const val KEY_INCLUDE_MEDIA = "include_media"
}
private var zipFile: DocumentFile? = null
override fun serialize(): ByteArray? {
return JsonJobData.Builder()
.putString(KEY_DESTINATION_URI, destinationUri)
@@ -82,8 +84,8 @@ class LocalPlaintextArchiveJob internal constructor(
val timestamp = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(Date())
val fileName = "signal-export-$timestamp"
val zipFile = root.createFile("application/zip", fileName)
if (zipFile == null) {
zipFile = root.createFile("application/zip", fileName)
val zipFile = this.zipFile ?: run {
Log.w(TAG, "Unable to create zip file")
return Result.failure()
}
@@ -111,8 +113,13 @@ class LocalPlaintextArchiveJob internal constructor(
ZipOutputStream(outputStream).use { zipOutputStream ->
val result = LocalArchiver.exportPlaintext(zipOutputStream, includeMedia, stopwatch, cancellationSignal = { isCanceled })
Log.i(TAG, "Plaintext archive finished with result: $result")
if (result !is org.signal.core.util.Result.Success) {
if (isCanceled) {
zipFile.delete()
setProgress(LocalBackupCreationProgress(canceled = LocalBackupCreationProgress.Canceled()), notification)
return Result.failure()
} else if (result !is org.signal.core.util.Result.Success) {
zipFile.delete()
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
return Result.failure()
}
}
@@ -121,10 +128,10 @@ class LocalPlaintextArchiveJob internal constructor(
}
stopwatch.split("archive-create")
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
setProgress(LocalBackupCreationProgress(succeeded = LocalBackupCreationProgress.Succeeded()), notification)
} catch (e: IOException) {
Log.w(TAG, "Error during plaintext archive!", e)
setProgress(LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle()), notification)
setProgress(LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed()), notification)
zipFile.delete()
throw e
}
@@ -138,7 +145,11 @@ class LocalPlaintextArchiveJob internal constructor(
}
override fun onFailure() {
SignalStore.backup.newLocalPlaintextBackupProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
zipFile?.delete()
val current = SignalStore.backup.newLocalPlaintextBackupProgress
if (current.canceled == null && current.failed == null) {
SignalStore.backup.newLocalPlaintextBackupProgress = LocalBackupCreationProgress(failed = LocalBackupCreationProgress.Failed())
}
}
private fun setProgress(progress: LocalBackupCreationProgress, notification: NotificationController?) {

View File

@@ -27,6 +27,10 @@ object Environment {
@JvmStatic
fun isNewFormatSupportedForLocalBackup(): Boolean = true
fun isLocalPlaintextBackupExportEnabled(): Boolean {
return isInternal()
}
}
object Donations {