mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-03 15:11:42 +01:00
Add plaintext chat history export UI.
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user