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

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