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 import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
val LocalBackupCreationProgress.isIdle: Boolean 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 { fun LocalBackupCreationProgress.exportProgress(): Float {
val exporting = exporting ?: return 0f 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.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding 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.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -22,6 +24,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.exportProgress import org.thoughtcrime.securesms.backup.exportProgress
import org.thoughtcrime.securesms.backup.transferProgress import org.thoughtcrime.securesms.backup.transferProgress
@@ -32,7 +35,8 @@ import org.signal.core.ui.R as CoreUiR
fun BackupCreationProgressRow( fun BackupCreationProgressRow(
progress: LocalBackupCreationProgress, progress: LocalBackupCreationProgress,
isRemote: Boolean, isRemote: Boolean,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
onCancel: (() -> Unit)? = null
) { ) {
Row( Row(
modifier = modifier modifier = modifier
@@ -42,7 +46,7 @@ fun BackupCreationProgressRow(
Column( Column(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
BackupCreationProgressIndicator(progress = progress) BackupCreationProgressIndicator(progress = progress, onCancel = onCancel)
Text( Text(
text = getProgressMessage(progress, isRemote), text = getProgressMessage(progress, isRemote),
@@ -55,7 +59,8 @@ fun BackupCreationProgressRow(
@Composable @Composable
private fun BackupCreationProgressIndicator( private fun BackupCreationProgressIndicator(
progress: LocalBackupCreationProgress progress: LocalBackupCreationProgress,
onCancel: (() -> Unit)? = null
) { ) {
val exporting = progress.exporting val exporting = progress.exporting
val transferring = progress.transferring val transferring = progress.transferring
@@ -93,6 +98,15 @@ private fun BackupCreationProgressIndicator(
.padding(vertical = 12.dp) .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 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 package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers 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.Rows
import org.signal.core.ui.compose.Scaffolds import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.Texts import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R 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.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.navigation.safeNavigate
/** /**
@@ -79,10 +88,38 @@ class ChatsSettingsFragment : ComposeFragment() {
override fun onEnterKeySendsChanged(enabled: Boolean) { override fun onEnterKeySendsChanged(enabled: Boolean) {
viewModel.setEnterKeySends(enabled) 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 onNavigationClick() = Unit
fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit
fun onUseAddressBookChanged(enabled: Boolean) = Unit fun onUseAddressBookChanged(enabled: Boolean) = Unit
@@ -91,8 +128,10 @@ private interface ChatsSettingsCallbacks {
fun onAddOrEditFoldersClick() = Unit fun onAddOrEditFoldersClick() = Unit
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
fun onEnterKeySendsChanged(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 @Composable
@@ -100,10 +139,25 @@ private fun ChatsSettingsScreen(
state: ChatsSettingsState, state: ChatsSettingsState,
callbacks: ChatsSettingsCallbacks 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( Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__chats), title = stringResource(R.string.preferences_chats__chats),
onNavigationClick = callbacks::onNavigationClick, onNavigationClick = callbacks::onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector navigationIcon = SignalIcons.ArrowStart.imageVector,
snackbarHost = {
Snackbars.Host(snackbarHostState)
}
) { paddingValues -> ) { paddingValues ->
LazyColumn( LazyColumn(
modifier = Modifier 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 { item {
Dividers.Default() Dividers.Default()
} }
@@ -194,6 +278,13 @@ private fun ChatsSettingsScreen(
} }
} }
} }
if (state.isPlaintextExportEnabled) {
ChatExportDialogs(
state = state.chatExportState,
callbacks = callbacks
)
}
} }
@DayNightPreviews @DayNightPreviews
@@ -210,7 +301,9 @@ private fun ChatsSettingsScreenPreview() {
localBackupsEnabled = true, localBackupsEnabled = true,
folderCount = 1, folderCount = 1,
userUnregistered = false, userUnregistered = false,
clientDeprecated = false clientDeprecated = false,
isPlaintextExportEnabled = true,
plaintextExportProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
), ),
callbacks = ChatsSettingsCallbacks.Empty callbacks = ChatsSettingsCallbacks.Empty
) )

View File

@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.chats package org.thoughtcrime.securesms.components.settings.app.chats
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
data class ChatsSettingsState( data class ChatsSettingsState(
val generateLinkPreviews: Boolean, val generateLinkPreviews: Boolean,
val useAddressBook: Boolean, val useAddressBook: Boolean,
@@ -9,7 +12,11 @@ data class ChatsSettingsState(
val localBackupsEnabled: Boolean, val localBackupsEnabled: Boolean,
val folderCount: Int, val folderCount: Int,
val userUnregistered: Boolean, 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 { fun isRegisteredAndUpToDate(): Boolean {
return !userUnregistered && !clientDeprecated return !userUnregistered && !clientDeprecated

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.chats package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -9,9 +10,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ThrottledDebouncer import org.thoughtcrime.securesms.util.ThrottledDebouncer
@@ -31,12 +34,53 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application), localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application),
folderCount = 0, folderCount = 0,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered, 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 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) { fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
store.update { it.copy(generateLinkPreviews = enabled) } store.update { it.copy(generateLinkPreviews = enabled) }
SignalStore.settings.isLinkPreviewsEnabled = 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); private static final String TAG = Log.tag(LocalBackupJob.class);
public static final String QUEUE = "__LOCAL_BACKUP__"; 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_PREFIX = ".backup";
public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp"; 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) { public static void enqueuePlaintextArchive(String destinationUri, boolean includeMedia) {
JobManager jobManager = AppDependencies.getJobManager(); JobManager jobManager = AppDependencies.getJobManager();
Parameters.Builder parameters = new Parameters.Builder() Parameters.Builder parameters = new Parameters.Builder()
.setQueue(QUEUE) .setQueue(PLAINTEXT_ARCHIVE_QUEUE)
.setMaxInstancesForFactory(1) .setMaxInstancesForFactory(1)
.setMaxAttempts(3); .setMaxAttempts(3);

View File

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

View File

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

View File

@@ -54,6 +54,8 @@ message ArchiveUploadProgressState {
message LocalBackupCreationProgress { message LocalBackupCreationProgress {
message Idle {} message Idle {}
message Canceled {} message Canceled {}
message Succeeded {}
message Failed {}
message Exporting { message Exporting {
ExportPhase phase = 1; ExportPhase phase = 1;
@@ -86,6 +88,8 @@ message LocalBackupCreationProgress {
Canceled canceled = 2; Canceled canceled = 2;
Exporting exporting = 3; Exporting exporting = 3;
Transferring transferring = 4; Transferring transferring = 4;
Succeeded succeeded = 5;
Failed failed = 6;
} }
} }

View File

@@ -5749,6 +5749,41 @@
<item quantity="other">%1$d folders</item> <item quantity="other">%1$d folders</item>
</plurals> </plurals>
<!-- Row title for the option to export chat history as a plaintext archive -->
<string name="ChatsSettingsFragment__export_chat_history">Export chat history</string>
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">Export a machine-readable JSON copy of all your chats. Disappearing messages will not be exported.</string>
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
<!-- ChatExportDialogs -->
<!-- Progress dialog message shown while canceling an in-progress chat export -->
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">Export chat history?</string>
<!-- Bold warning prefix in the export confirmation dialog body -->
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
<string name="ChatExportDialogs__export_confirm_body">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.</string>
<!-- Button in the export confirmation dialog to export messages and media -->
<string name="ChatExportDialogs__export_with_media">Export with media</string>
<!-- Button in the export confirmation dialog to export messages only -->
<string name="ChatExportDialogs__export_without_media">Export without media</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">Choose a folder</string>
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">Choose folder</string>
<!-- Title of the dialog shown when a chat export finishes successfully -->
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
<string name="ChatExportDialogs__complete_body">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.</string>
<!-- ChatFoldersEducationSheet --> <!-- ChatFoldersEducationSheet -->
<!-- Text in a bottom sheet describing chat folders and what they can be created for --> <!-- Text in a bottom sheet describing chat folders and what they can be created for -->
<string name="ChatFoldersEducationSheet__create_folders_for_family">Create folders for family, friends, work and more</string> <string name="ChatFoldersEducationSheet__create_folders_for_family">Create folders for family, friends, work and more</string>

View File

@@ -53,6 +53,7 @@ import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -162,6 +163,37 @@ object Dialogs {
confirmColor: Color = Color.Unspecified, confirmColor: Color = Color.Unspecified,
dismissColor: Color = Color.Unspecified, dismissColor: Color = Color.Unspecified,
properties: DialogProperties = DialogProperties() 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( BaseAlertDialog(
onDismissRequest = onDismissRequest, 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) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun PermissionRationaleDialog( fun PermissionRationaleDialog(
@@ -602,6 +692,35 @@ object Dialogs {
onNegative: () -> Unit, onNegative: () -> Unit,
onNeutral: () -> Unit, onNeutral: () -> Unit,
properties: DialogProperties = DialogProperties() 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( Dialog(
onDismissRequest = onNegative, 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 @DayNightPreviews
@Composable @Composable
private fun RadioListDialogPreview() { private fun RadioListDialogPreview() {