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

View File

@@ -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 = {}
)
}
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Launchers
import org.thoughtcrime.securesms.R
/**
* Dialogs displayed while processing a user's decrypted chat export.
*
* Displayed *after* the user has confirmed via phone auth.
*/
@Composable
fun ChatExportDialogs(state: ChatExportState, callbacks: ChatExportCallbacks) {
val folderPicker = Launchers.rememberOpenDocumentTreeLauncher {
if (it != null) {
callbacks.onFolderSelected(it)
} else {
callbacks.onCancelStartExport()
}
}
when (state) {
ChatExportState.None -> Unit
ChatExportState.ConfirmExport -> ConfirmExportDialog(
onConfirmExport = callbacks::onConfirmExport,
onCancel = callbacks::onCancelStartExport
)
ChatExportState.ChooseAFolder -> ChooseAFolderDialog(
onChooseAFolder = { folderPicker.launch(null) },
onCancel = callbacks::onCancelStartExport
)
ChatExportState.Canceling -> Dialogs.IndeterminateProgressDialog(message = stringResource(R.string.ChatExportDialogs__canceling_export))
ChatExportState.Success -> CompleteDialog(
onOK = callbacks::onCompletionConfirmed
)
}
}
@Composable
private fun ConfirmExportDialog(
onConfirmExport: (withMedia: Boolean) -> Unit,
onCancel: () -> Unit
) {
val body = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.ChatExportDialogs__be_careful_warning))
}
append(" ")
append(stringResource(R.string.ChatExportDialogs__export_confirm_body))
}
Dialogs.AdvancedAlertDialog(
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_chat_history_title)),
body = body,
positive = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_with_media)),
neutral = AnnotatedString(stringResource(R.string.ChatExportDialogs__export_without_media)),
negative = AnnotatedString(stringResource(android.R.string.cancel)),
onPositive = { onConfirmExport(true) },
onNeutral = { onConfirmExport(false) },
onNegative = onCancel
)
}
@Composable
private fun ChooseAFolderDialog(
onChooseAFolder: () -> Unit,
onCancel: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.ChatExportDialogs__choose_a_folder_title),
body = stringResource(R.string.ChatExportDialogs__choose_a_folder_body),
confirm = stringResource(R.string.ChatExportDialogs__choose_folder_button),
dismiss = stringResource(android.R.string.cancel),
onConfirm = onChooseAFolder,
onDeny = onCancel
)
}
@Composable
private fun CompleteDialog(
onOK: () -> Unit
) {
val body = buildAnnotatedString {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(stringResource(R.string.ChatExportDialogs__be_careful))
}
append(" ")
append(stringResource(R.string.ChatExportDialogs__complete_body))
}
Dialogs.SimpleAlertDialog(
title = AnnotatedString(stringResource(R.string.ChatExportDialogs__complete_title)),
body = body,
confirm = AnnotatedString(stringResource(android.R.string.ok)),
onConfirm = onOK
)
}
enum class ChatExportState {
None,
ConfirmExport,
ChooseAFolder,
Canceling,
Success
}
interface ChatExportCallbacks {
fun onConfirmExport(withMedia: Boolean)
fun onFolderSelected(uri: Uri)
fun onCancelStartExport()
fun onCompletionConfirmed()
object Empty : ChatExportCallbacks {
override fun onConfirmExport(withMedia: Boolean) = Unit
override fun onFolderSelected(uri: Uri) = Unit
override fun onCancelStartExport() = Unit
override fun onCompletionConfirmed() = Unit
}
}

View File

@@ -1,16 +1,20 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers
@@ -18,9 +22,14 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.isIdle
import org.thoughtcrime.securesms.backup.v2.ui.status.BackupCreationProgressRow
import org.thoughtcrime.securesms.components.compose.rememberBiometricsAuthentication
import org.thoughtcrime.securesms.compose.rememberStatusBarColorNestedScrollModifier
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
@@ -79,10 +88,38 @@ class ChatsSettingsFragment : ComposeFragment() {
override fun onEnterKeySendsChanged(enabled: Boolean) {
viewModel.setEnterKeySends(enabled)
}
override fun onExportPlaintextChatHistoryClick() {
viewModel.requestChatExportType()
}
override fun onCancelInFlightExport() {
viewModel.cancelChatExport()
}
// region ChatExportCallback
override fun onConfirmExport(withMedia: Boolean) {
viewModel.setExportTypeAndGoToSelectFolder(withMedia)
}
override fun onFolderSelected(uri: Uri) {
viewModel.startChatExportToFolder(uri)
}
override fun onCancelStartExport() {
viewModel.clearChatExportFlow()
}
override fun onCompletionConfirmed() {
viewModel.clearChatExportFlow()
}
// endregion
}
}
private interface ChatsSettingsCallbacks {
private interface ChatsSettingsCallbacks : ChatExportCallbacks {
fun onNavigationClick() = Unit
fun onGenerateLinkPreviewsChanged(enabled: Boolean) = Unit
fun onUseAddressBookChanged(enabled: Boolean) = Unit
@@ -91,8 +128,10 @@ private interface ChatsSettingsCallbacks {
fun onAddOrEditFoldersClick() = Unit
fun onUseSystemEmojiChanged(enabled: Boolean) = Unit
fun onEnterKeySendsChanged(enabled: Boolean) = Unit
fun onExportPlaintextChatHistoryClick() = Unit
fun onCancelInFlightExport() = Unit
object Empty : ChatsSettingsCallbacks
object Empty : ChatsSettingsCallbacks, ChatExportCallbacks by ChatExportCallbacks.Empty
}
@Composable
@@ -100,10 +139,25 @@ private fun ChatsSettingsScreen(
state: ChatsSettingsState,
callbacks: ChatsSettingsCallbacks
) {
val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val authenticationFailedMessage = stringResource(R.string.ChatsSettingsFragment__authentication_failed)
val plaintextBiometricsAuthentication = rememberBiometricsAuthentication(
promptTitle = stringResource(R.string.ChatsSettingsFragment__unlock_to_export_chat_history),
onAuthenticationFailed = {
coroutineScope.launch {
snackbarHostState.showSnackbar(authenticationFailedMessage)
}
}
)
Scaffolds.Settings(
title = stringResource(R.string.preferences_chats__chats),
onNavigationClick = callbacks::onNavigationClick,
navigationIcon = SignalIcons.ArrowStart.imageVector
navigationIcon = SignalIcons.ArrowStart.imageVector,
snackbarHost = {
Snackbars.Host(snackbarHostState)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier
@@ -167,6 +221,36 @@ private fun ChatsSettingsScreen(
}
}
if (state.isPlaintextExportEnabled) {
item {
Dividers.Default()
}
if (state.plaintextExportProgress.isIdle) {
item(key = "export_chat_history_row") {
Rows.TextRow(
modifier = Modifier.animateItem(),
text = stringResource(R.string.ChatsSettingsFragment__export_chat_history),
label = stringResource(R.string.ChatsSettingsFragment__export_chat_history_label),
onClick = {
plaintextBiometricsAuthentication.withBiometricsAuthentication {
callbacks.onExportPlaintextChatHistoryClick()
}
}
)
}
} else {
item(key = "export_chat_history_progress") {
BackupCreationProgressRow(
modifier = Modifier.animateItem(),
progress = state.plaintextExportProgress,
isRemote = false,
onCancel = callbacks::onCancelInFlightExport
)
}
}
}
item {
Dividers.Default()
}
@@ -194,6 +278,13 @@ private fun ChatsSettingsScreen(
}
}
}
if (state.isPlaintextExportEnabled) {
ChatExportDialogs(
state = state.chatExportState,
callbacks = callbacks
)
}
}
@DayNightPreviews
@@ -210,7 +301,9 @@ private fun ChatsSettingsScreenPreview() {
localBackupsEnabled = true,
folderCount = 1,
userUnregistered = false,
clientDeprecated = false
clientDeprecated = false,
isPlaintextExportEnabled = true,
plaintextExportProgress = LocalBackupCreationProgress(idle = LocalBackupCreationProgress.Idle())
),
callbacks = ChatsSettingsCallbacks.Empty
)

View File

@@ -1,5 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.protos.LocalBackupCreationProgress
data class ChatsSettingsState(
val generateLinkPreviews: Boolean,
val useAddressBook: Boolean,
@@ -9,7 +12,11 @@ data class ChatsSettingsState(
val localBackupsEnabled: Boolean,
val folderCount: Int,
val userUnregistered: Boolean,
val clientDeprecated: Boolean
val clientDeprecated: Boolean,
val isPlaintextExportEnabled: Boolean,
val plaintextExportProgress: LocalBackupCreationProgress = SignalStore.backup.newLocalPlaintextBackupProgress,
val chatExportState: ChatExportState = ChatExportState.None,
val includeMediaInExport: Boolean = false
) {
fun isRegisteredAndUpToDate(): Boolean {
return !userUnregistered && !clientDeprecated

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
@@ -9,9 +10,11 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFoldersRepository
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ThrottledDebouncer
@@ -31,12 +34,53 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
localBackupsEnabled = SignalStore.settings.isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(AppDependencies.application),
folderCount = 0,
userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application) || !SignalStore.account.isRegistered,
clientDeprecated = SignalStore.misc.isClientDeprecated
clientDeprecated = SignalStore.misc.isClientDeprecated,
isPlaintextExportEnabled = Environment.Backups.isLocalPlaintextBackupExportEnabled(),
chatExportState = ChatExportState.None
)
)
val state: StateFlow<ChatsSettingsState> = store
init {
viewModelScope.launch {
SignalStore.backup.newLocalPlaintextBackupProgressFlow.collect { progress ->
store.update {
it.copy(
plaintextExportProgress = progress,
chatExportState = when {
progress.succeeded != null && it.plaintextExportProgress.succeeded == null -> ChatExportState.Success
progress.canceled != null -> ChatExportState.None
else -> it.chatExportState
}
)
}
}
}
}
fun requestChatExportType() {
store.update { it.copy(chatExportState = ChatExportState.ConfirmExport) }
}
fun setExportTypeAndGoToSelectFolder(includeMediaInExport: Boolean) {
store.update { it.copy(chatExportState = ChatExportState.ChooseAFolder, includeMediaInExport = includeMediaInExport) }
}
fun startChatExportToFolder(uri: Uri) {
store.update { it.copy(chatExportState = ChatExportState.None) }
LocalBackupJob.enqueuePlaintextArchive(uri.toString(), store.value.includeMediaInExport)
}
fun clearChatExportFlow() {
store.update { it.copy(chatExportState = ChatExportState.None, includeMediaInExport = false) }
}
fun cancelChatExport() {
store.update { it.copy(chatExportState = ChatExportState.Canceling) }
AppDependencies.jobManager.cancelAllInQueue(LocalBackupJob.PLAINTEXT_ARCHIVE_QUEUE)
}
fun setGenerateLinkPreviewsEnabled(enabled: Boolean) {
store.update { it.copy(generateLinkPreviews = enabled) }
SignalStore.settings.isLinkPreviewsEnabled = enabled

View File

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

View File

@@ -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?) {

View File

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

View File

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

View File

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

View File

@@ -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() {