mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 00:17:41 +01:00
Add plaintext chat history export UI.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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?) {
|
||||
|
||||
@@ -27,6 +27,10 @@ object Environment {
|
||||
|
||||
@JvmStatic
|
||||
fun isNewFormatSupportedForLocalBackup(): Boolean = true
|
||||
|
||||
fun isLocalPlaintextBackupExportEnabled(): Boolean {
|
||||
return isInternal()
|
||||
}
|
||||
}
|
||||
|
||||
object Donations {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5749,6 +5749,41 @@
|
||||
<item quantity="other">%1$d folders</item>
|
||||
</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 -->
|
||||
<!-- 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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user