From 4c76cb682ecf374e07a772080d0662e321ce1728 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 7 Apr 2026 12:13:12 -0400 Subject: [PATCH] Give a media/no-media choice in labs plaintext export. --- .../plaintext/PlaintextExportRepository.kt | 113 +++++++++++------- .../conversation/v2/ConversationFragment.kt | 17 ++- .../conversation/v2/ConversationViewModel.kt | 15 ++- .../PlaintextExportRepositoryTest.kt | 3 +- 4 files changed, 97 insertions(+), 51 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepository.kt index c468c8c034..4646fddb26 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepository.kt @@ -47,6 +47,7 @@ object PlaintextExportRepository { threadId: Long, directoryUri: Uri, chatName: String, + includeMedia: Boolean, progressListener: ProgressListener, cancellationSignal: CancellationSignal ): Boolean { @@ -70,9 +71,13 @@ object PlaintextExportRepository { return false } - val mediaDir = chatDir.createDirectory("media") ?: run { - Log.w(TAG, "Could not create media directory") - return false + val mediaDir = if (includeMedia) { + chatDir.createDirectory("media") ?: run { + Log.w(TAG, "Could not create media directory") + return false + } + } else { + null } val chatFile = chatDir.createFile("text/plain", "chat.txt") ?: run { @@ -117,11 +122,15 @@ object PlaintextExportRepository { for (message in batch) { if (cancellationSignal.isCancelled()) return false - writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments) + writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments, includeMedia) writer.newLine() messagesProcessed++ - progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount) + if (includeMedia) { + progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount) + } else { + progressListener.onProgress(messagesProcessed, stats.messageCount, 0, 0) + } } eventTimer.emit("messages") } @@ -136,32 +145,34 @@ object PlaintextExportRepository { // Attachments — use createFile directly (like LocalArchiver's FilesFileSystem) to avoid // the extra content resolver queries that newFile/findFile perform. - val totalAttachments = pendingAttachments.size - var attachmentsProcessed = 0 - for (pending in pendingAttachments) { - if (cancellationSignal.isCancelled()) return false + if (includeMedia && mediaDir != null) { + val totalAttachments = pendingAttachments.size + var attachmentsProcessed = 0 + for (pending in pendingAttachments) { + if (cancellationSignal.isCancelled()) return false - try { - val outputStream = mediaDir.createFile("application/octet-stream", pending.exportedName)?.let { it.outputStream(context) } - if (outputStream == null) { - Log.w(TAG, "Could not create attachment file: ${pending.exportedName}") - attachmentsProcessed++ - progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments) - continue - } - - outputStream.use { out -> - SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input -> - input.copyTo(out) + try { + val outputStream = mediaDir.createFile("application/octet-stream", pending.exportedName)?.let { it.outputStream(context) } + if (outputStream == null) { + Log.w(TAG, "Could not create attachment file: ${pending.exportedName}") + attachmentsProcessed++ + progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments) + continue } - } - } catch (e: Exception) { - Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e) - } - attachmentsProcessed++ - progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments) - eventTimer.emit("media") + outputStream.use { out -> + SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input -> + input.copyTo(out) + } + } + } catch (e: Exception) { + Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e) + } + + attachmentsProcessed++ + progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments) + eventTimer.emit("media") + } } Log.d(TAG, "[PlaintextExport] ${eventTimer.stop().summary}") @@ -222,7 +233,8 @@ object PlaintextExportRepository { extraData: ExtraMessageData, dateFormat: SimpleDateFormat, attachmentDateFormat: SimpleDateFormat, - pendingAttachments: MutableList + pendingAttachments: MutableList, + includeMedia: Boolean ) { val timestamp = dateFormat.format(Date(message.dateSent)) @@ -262,7 +274,7 @@ object PlaintextExportRepository { } if (stickerAttachment != null) { - this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments) + this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments, includeMedia) return } @@ -282,7 +294,7 @@ object PlaintextExportRepository { } val wrotePrefix = !body.isNullOrEmpty() || hasQuote - this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments) + this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments, includeMedia) } private fun BufferedWriter.writeUpdateMessage(context: Context, message: MmsMessageRecord, timestamp: String) { @@ -323,15 +335,20 @@ object PlaintextExportRepository { prefix: String, hasQuote: Boolean, attachmentDateFormat: SimpleDateFormat, - pendingAttachments: MutableList + pendingAttachments: MutableList, + includeMedia: Boolean ) { val emoji = stickerAttachment.stickerLocator?.emoji ?: "" - val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat) - pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName)) if (!hasQuote) { this.write(prefix) } - this.write("(Sticker) $emoji [See: media/$exportedName]") + if (includeMedia) { + val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat) + pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName)) + this.write("(Sticker) $emoji [See: media/$exportedName]") + } else { + this.write("(Sticker) $emoji") + } this.newLine() } @@ -340,23 +357,33 @@ object PlaintextExportRepository { prefix: String, wrotePrefix: Boolean, attachmentDateFormat: SimpleDateFormat, - pendingAttachments: MutableList + pendingAttachments: MutableList, + includeMedia: Boolean ) { for ((index, attachment) in attachments.withIndex()) { - val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat) - pendingAttachments.add(PendingAttachment(attachment, exportedName)) - val label = getAttachmentLabel(attachment) if (!wrotePrefix && index == 0) { this.write(prefix) } - val caption = attachment.caption - if (caption != null) { - this.write("[$label: media/$exportedName] $caption") + if (includeMedia) { + val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat) + pendingAttachments.add(PendingAttachment(attachment, exportedName)) + + val caption = attachment.caption + if (caption != null) { + this.write("[$label: media/$exportedName] $caption") + } else { + this.write("[$label: media/$exportedName]") + } } else { - this.write("[$label: media/$exportedName]") + val caption = attachment.caption + if (caption != null) { + this.write("[$label] $caption") + } else { + this.write("[$label]") + } } this.newLine() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index c5bd9f0b7e..ee1f40af3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -552,6 +552,7 @@ class ConversationFragment : private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler private lateinit var addToContactsLauncher: ActivityResultLauncher private lateinit var plaintextExportDirectoryLauncher: ActivityResultLauncher + private var exportWithMedia = false private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate private lateinit var adapter: ConversationAdapterV2 @@ -1604,7 +1605,7 @@ class ConversationFragment : if (uri != null) { val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags) - viewModel.startPlaintextExport(requireContext().applicationContext, uri) + viewModel.startPlaintextExport(requireContext().applicationContext, uri, exportWithMedia) } } conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks()) @@ -4292,7 +4293,19 @@ class ConversationFragment : } override fun handleExportChat() { - plaintextExportDirectoryLauncher.launch(null) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.ChatExportDialogs__export_chat_history_title) + .setMessage(R.string.ChatExportDialogs__export_confirm_body) + .setPositiveButton(R.string.ChatExportDialogs__export_with_media) { _, _ -> + exportWithMedia = true + plaintextExportDirectoryLauncher.launch(null) + } + .setNeutralButton(R.string.ChatExportDialogs__export_without_media) { _, _ -> + exportWithMedia = false + plaintextExportDirectoryLauncher.launch(null) + } + .setNegativeButton(android.R.string.cancel, null) + .show() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index a8df99ef38..790cbc9a90 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -761,7 +761,7 @@ class ConversationViewModel( } } - fun startPlaintextExport(context: Context, directoryUri: Uri) { + fun startPlaintextExport(context: Context, directoryUri: Uri, withMedia: Boolean) { val recipient = recipientSnapshot ?: return val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context) @@ -774,12 +774,17 @@ class ConversationViewModel( threadId = threadId, directoryUri = directoryUri, chatName = chatName, + includeMedia = withMedia, progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount -> - val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25 - val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75 - val percent = messagePercent + attachmentPercent + val percent = if (withMedia) { + val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25 + val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75 + messagePercent + attachmentPercent + } else { + if (messageCount > 0) (messagesProcessed * 100) / messageCount else 100 + } - val status = if (attachmentsProcessed > 0 || messagesProcessed >= messageCount) { + val status = if (withMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) { "Exporting media ($attachmentsProcessed/$attachmentCount)..." } else { "Exporting messages ($messagesProcessed/$messageCount)..." diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepositoryTest.kt index 0b6ff5817e..380053d284 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepositoryTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepositoryTest.kt @@ -767,7 +767,8 @@ class PlaintextExportRepositoryTest { extraData = extraData, dateFormat = dateFormat, attachmentDateFormat = attachmentDateFormat, - pendingAttachments = pendingAttachments + pendingAttachments = pendingAttachments, + includeMedia = true ) }