diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationProgress.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationProgress.kt index 2bd1d16511..89926bcf68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationProgress.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupCreationProgress.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupCreationProgressRow.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupCreationProgressRow.kt index 740c440a2f..4ae43b00b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupCreationProgressRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/status/BackupCreationProgressRow.kt @@ -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 = {} ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatExportDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatExportDialogs.kt new file mode 100644 index 0000000000..eab346a16c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatExportDialogs.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt index db07cb94f0..266000060c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsFragment.kt @@ -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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt index c680827ae0..eae6b2015d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsState.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt index 03f0e3f984..602093cac9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/chats/ChatsSettingsViewModel.kt @@ -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 = 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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java index e29bb3e3a9..0d6870b5d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalPlaintextArchiveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalPlaintextArchiveJob.kt index d98da8098f..cfe718d565 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalPlaintextArchiveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalPlaintextArchiveJob.kt @@ -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?) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index 3f0912f4d2..6bb3411943 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -27,6 +27,10 @@ object Environment { @JvmStatic fun isNewFormatSupportedForLocalBackup(): Boolean = true + + fun isLocalPlaintextBackupExportEnabled(): Boolean { + return isInternal() + } } object Donations { diff --git a/app/src/main/protowire/KeyValue.proto b/app/src/main/protowire/KeyValue.proto index 19a297d628..d71d918ed5 100644 --- a/app/src/main/protowire/KeyValue.proto +++ b/app/src/main/protowire/KeyValue.proto @@ -54,6 +54,8 @@ message ArchiveUploadProgressState { message LocalBackupCreationProgress { message Idle {} message Canceled {} + message Succeeded {} + message Failed {} message Exporting { ExportPhase phase = 1; @@ -86,6 +88,8 @@ message LocalBackupCreationProgress { Canceled canceled = 2; Exporting exporting = 3; Transferring transferring = 4; + Succeeded succeeded = 5; + Failed failed = 6; } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9f42358e98..c7a9f5d53d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5749,6 +5749,41 @@ %1$d folders + + Export chat history + + Export a machine-readable JSON copy of all your chats. Disappearing messages will not be exported. + + Unlock to export chat history + + Authentication failed + + + + Canceling export… + + Export chat history? + + BE CAREFUL! + + Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size. + + Export with media + + Export without media + + Choose a folder + + Choose a folder in your device\'s storage where your chat history will be stored. + + Choose folder + + Chat export complete + + BE CAREFUL + + where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions. + Create folders for family, friends, work and more diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt index 9233a70a75..1bbcab410f 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Dialogs.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -162,6 +163,37 @@ object Dialogs { confirmColor: Color = Color.Unspecified, dismissColor: Color = Color.Unspecified, properties: DialogProperties = DialogProperties() + ) { + SimpleAlertDialog( + title = AnnotatedString(title), + body = AnnotatedString(body), + confirm = AnnotatedString(confirm), + onConfirm = onConfirm, + onDismiss = onDismiss, + onDismissRequest = onDismissRequest, + onDeny = onDeny, + modifier = modifier, + dismiss = AnnotatedString(dismiss), + confirmColor = confirmColor, + dismissColor = dismissColor, + properties = properties + ) + } + + @Composable + fun SimpleAlertDialog( + title: AnnotatedString, + body: AnnotatedString, + confirm: AnnotatedString, + onConfirm: () -> Unit, + onDismiss: () -> Unit = {}, + onDismissRequest: () -> Unit = onDismiss, + onDeny: () -> Unit = {}, + modifier: Modifier = Modifier, + dismiss: AnnotatedString = AnnotatedString(NoDismiss), + confirmColor: Color = Color.Unspecified, + dismissColor: Color = Color.Unspecified, + properties: DialogProperties = DialogProperties() ) { BaseAlertDialog( onDismissRequest = onDismissRequest, @@ -348,6 +380,64 @@ object Dialogs { ) } + /** + * Customizable progress dialog that can be dismissed while showing [message] + * and [caption]. When [indeterminate] is true a circular spinner is shown, + * otherwise a linear progress bar driven by [progress] is shown. + */ + @Composable + fun ProgressDialog( + message: String, + caption: String = "", + dismiss: String, + onDismiss: () -> Unit, + indeterminate: Boolean, + progress: () -> Float + ) { + BaseAlertDialog( + onDismissRequest = {}, + confirmButton = {}, + dismissButton = { + TextButton( + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + content = { Text(text = dismiss) } + ) + }, + text = { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.size(32.dp)) + if (indeterminate) { + CircularProgressIndicator() + } else { + CircularProgressIndicator(progress = progress) + } + Spacer(modifier = Modifier.size(12.dp)) + Text( + text = message, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (caption.isNotEmpty()) { + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = caption, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + modifier = Modifier.width(200.dp) + ) + } + @OptIn(ExperimentalLayoutApi::class) @Composable fun PermissionRationaleDialog( @@ -602,6 +692,35 @@ object Dialogs { onNegative: () -> Unit, onNeutral: () -> Unit, properties: DialogProperties = DialogProperties() + ) { + AdvancedAlertDialog( + title = AnnotatedString(title), + body = AnnotatedString(body), + positive = AnnotatedString(positive), + neutral = AnnotatedString(neutral), + negative = AnnotatedString(negative), + onPositive = onPositive, + onNegative = onNegative, + onNeutral = onNeutral, + properties = properties + ) + } + + /** + * Alert dialog that supports three options. + * If you only need two options (confirm/dismiss), use [SimpleAlertDialog] instead. + */ + @Composable + fun AdvancedAlertDialog( + title: AnnotatedString = AnnotatedString(""), + body: AnnotatedString = AnnotatedString(""), + positive: AnnotatedString, + neutral: AnnotatedString, + negative: AnnotatedString, + onPositive: () -> Unit, + onNegative: () -> Unit, + onNeutral: () -> Unit, + properties: DialogProperties = DialogProperties() ) { Dialog( onDismissRequest = onNegative, @@ -746,6 +865,22 @@ private fun IndeterminateProgressDialogCancellablePreview() { } } +@DayNightPreviews +@Composable +private fun ProgressDialogIndeterminatePreview() { + Previews.Preview { + Dialogs.ProgressDialog("Exporting...", "Do not close app", "Cancel", {}, indeterminate = true, progress = { 0f }) + } +} + +@DayNightPreviews +@Composable +private fun ProgressDialogDeterminatePreview() { + Previews.Preview { + Dialogs.ProgressDialog("Exporting...", "Do not close app", "Cancel", {}, indeterminate = false, progress = { 0.6f }) + } +} + @DayNightPreviews @Composable private fun RadioListDialogPreview() {