From 78940ffc174b4f687fe661f4a52b5583e16cd613 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 13 Apr 2026 16:43:10 -0400 Subject: [PATCH] Switch the labs plaintext export to share a single zip. --- .../plaintext/PlaintextExportRepository.kt | 103 ++++++------------ .../conversation/v2/ConversationFragment.kt | 34 +++--- .../conversation/v2/ConversationViewModel.kt | 27 +++-- 3 files changed, 71 insertions(+), 93 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 4646fddb26..055fe13f24 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 @@ -6,13 +6,10 @@ package org.thoughtcrime.securesms.conversation.plaintext import android.content.Context -import android.net.Uri import android.webkit.MimeTypeMap import androidx.annotation.VisibleForTesting -import androidx.documentfile.provider.DocumentFile import org.signal.core.util.EventTimer import org.signal.core.util.ParallelEventTimer -import org.signal.core.util.androidx.DocumentFileUtil.outputStream import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.attachments.DatabaseAttachment @@ -25,14 +22,19 @@ import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.polls.PollRecord import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.MediaUtil +import java.io.BufferedOutputStream import java.io.BufferedWriter +import java.io.File import java.io.IOException +import java.io.OutputStreamWriter import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.Callable import java.util.concurrent.ExecutorService import java.util.concurrent.Future +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream /** * Exports a conversation thread as user-friendly plaintext with attachments. @@ -45,7 +47,7 @@ object PlaintextExportRepository { fun export( context: Context, threadId: Long, - directoryUri: Uri, + outputFile: File, chatName: String, includeMedia: Boolean, progressListener: ProgressListener, @@ -55,49 +57,18 @@ object PlaintextExportRepository { val stats = getExportStats(threadId) eventTimer.emit("stats") - val root = DocumentFile.fromTreeUri(context, directoryUri) ?: run { - Log.w(TAG, "Could not open directory") - return false - } - val sanitizedName = sanitizeFileName(chatName) - if (root.findFile(sanitizedName) != null) { - Log.w(TAG, "Export folder already exists: $sanitizedName") - return false - } - - val chatDir = root.createDirectory(sanitizedName) ?: run { - Log.w(TAG, "Could not create chat 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 { - Log.w(TAG, "Could not create chat.txt") - return false - } - val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) val attachmentDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US) val pendingAttachments = mutableListOf() var messagesProcessed = 0 - val outputStream = chatFile.outputStream(context) ?: run { - Log.w(TAG, "Could not open chat.txt for writing") - return false - } - try { - outputStream.bufferedWriter().use { writer -> + ZipOutputStream(BufferedOutputStream(outputFile.outputStream())).use { zipOut -> + zipOut.putNextEntry(ZipEntry("$sanitizedName/chat.txt")) + val writer = BufferedWriter(OutputStreamWriter(zipOut, Charsets.UTF_8)) + writer.write("Chat export: $chatName") writer.newLine() writer.write("Exported on: ${dateFormat.format(Date())}") @@ -108,7 +79,6 @@ object PlaintextExportRepository { val extraDataTimer = ParallelEventTimer() - // Messages MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId, dateReceiveOrderBy = "ASC")).use { reader -> while (true) { if (cancellationSignal.isCancelled()) return false @@ -137,42 +107,36 @@ object PlaintextExportRepository { } Log.d(TAG, "[PlaintextExport] ${extraDataTimer.stop().summary}") - } - } catch (e: IOException) { - Log.w(TAG, "Error writing chat.txt", e) - return false - } - // Attachments — use createFile directly (like LocalArchiver's FilesFileSystem) to avoid - // the extra content resolver queries that newFile/findFile perform. - if (includeMedia && mediaDir != null) { - val totalAttachments = pendingAttachments.size - var attachmentsProcessed = 0 - for (pending in pendingAttachments) { - if (cancellationSignal.isCancelled()) return false + writer.flush() + zipOut.closeEntry() + + if (includeMedia) { + val totalAttachments = pendingAttachments.size + var attachmentsProcessed = 0 + for (pending in pendingAttachments) { + if (cancellationSignal.isCancelled()) return false + + try { + zipOut.putNextEntry(ZipEntry("$sanitizedName/media/${pending.exportedName}")) + SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input -> + input.copyTo(zipOut) + } + zipOut.closeEntry() + } catch (e: Exception) { + Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e) + } - 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 + 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") } + } catch (e: IOException) { + Log.w(TAG, "Error writing export zip", e) + outputFile.delete() + return false } Log.d(TAG, "[PlaintextExport] ${eventTimer.stop().summary}") @@ -370,7 +334,6 @@ object PlaintextExportRepository { 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") 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 34c31eedbb..e7bf0d7e2a 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 @@ -11,6 +11,7 @@ import android.app.ActivityOptions import android.app.PendingIntent import android.content.ActivityNotFoundException import android.content.BroadcastReceiver +import android.content.ComponentName import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -21,6 +22,7 @@ import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.graphics.Rect import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -106,7 +108,6 @@ import org.greenrobot.eventbus.ThreadMode import org.signal.core.models.media.Media import org.signal.core.models.media.TransformProperties import org.signal.core.ui.BottomSheetUtil -import org.signal.core.ui.contracts.OpenDocumentTreeContract import org.signal.core.ui.getWindowSizeClass import org.signal.core.ui.isSplitPane import org.signal.core.ui.logging.LoggingFragment @@ -332,6 +333,7 @@ import org.thoughtcrime.securesms.registration.ui.RegistrationActivity import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity import org.thoughtcrime.securesms.revealable.ViewOnceUtil import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet +import org.thoughtcrime.securesms.sharing.v2.ShareActivity import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.stickers.StickerEventListener import org.thoughtcrime.securesms.stickers.StickerLocator @@ -350,6 +352,7 @@ import org.thoughtcrime.securesms.util.DeleteDialog import org.thoughtcrime.securesms.util.Dialogs import org.thoughtcrime.securesms.util.DoubleClickDebouncer import org.thoughtcrime.securesms.util.DrawableUtil +import org.thoughtcrime.securesms.util.FileProviderUtil import org.thoughtcrime.securesms.util.FullscreenHelper import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MessageConstraintsUtil @@ -562,8 +565,6 @@ class ConversationFragment : private lateinit var markReadHelper: MarkReadHelper 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 @@ -1633,13 +1634,6 @@ class ConversationFragment : private fun registerForResults() { addToContactsLauncher = registerForActivityResult(AddToContactsContract()) {} - plaintextExportDirectoryLauncher = registerForActivityResult(OpenDocumentTreeContract()) { uri -> - 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, exportWithMedia) - } - } conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks()) } @@ -1684,7 +1678,19 @@ class ConversationFragment : is ConversationViewModel.PlaintextExportState.Complete -> { progressDialog?.dismiss() progressDialog = null - toast(R.string.conversation_export__export_complete, toastDuration = Toast.LENGTH_LONG) + + val uri = FileProviderUtil.getUriFor(requireContext(), state.zipFile) + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooserIntent = Intent.createChooser(shareIntent, getString(R.string.conversation_export__export_complete)) + if (Build.VERSION.SDK_INT < 34) { + chooserIntent.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(requireContext(), ShareActivity::class.java))) + } + startActivity(chooserIntent) + viewModel.clearPlaintextExportState() } @@ -4330,12 +4336,10 @@ class ConversationFragment : .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) + viewModel.startPlaintextExport(requireContext().applicationContext, includeMedia = true) } .setNeutralButton(R.string.ChatExportDialogs__export_without_media) { _, _ -> - exportWithMedia = false - plaintextExportDirectoryLauncher.launch(null) + viewModel.startPlaintextExport(requireContext().applicationContext, includeMedia = false) } .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 790cbc9a90..30c5d45496 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 @@ -101,6 +101,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.hasGiftBadge import org.thoughtcrime.securesms.util.rx.RxStore import org.thoughtcrime.securesms.wallpaper.ChatWallpaper +import java.io.File import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration @@ -761,10 +762,17 @@ class ConversationViewModel( } } - fun startPlaintextExport(context: Context, directoryUri: Uri, withMedia: Boolean) { + fun startPlaintextExport(context: Context, includeMedia: Boolean) { val recipient = recipientSnapshot ?: return val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context) + val exportDir = File(context.externalCacheDir, "chat_exports") + exportDir.mkdirs() + exportDir.listFiles()?.forEach { it.delete() } + + val sanitizedName = PlaintextExportRepository.sanitizeFileName(chatName) + val outputFile = File(exportDir, "$sanitizedName.zip") + plaintextExportCancelled.set(false) _plaintextExportState.value = PlaintextExportState.Preparing @@ -772,11 +780,11 @@ class ConversationViewModel( val success = PlaintextExportRepository.export( context = context, threadId = threadId, - directoryUri = directoryUri, + outputFile = outputFile, chatName = chatName, - includeMedia = withMedia, + includeMedia = includeMedia, progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount -> - val percent = if (withMedia) { + val percent = if (includeMedia) { val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25 val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75 messagePercent + attachmentPercent @@ -784,7 +792,7 @@ class ConversationViewModel( if (messageCount > 0) (messagesProcessed * 100) / messageCount else 100 } - val status = if (withMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) { + val status = if (includeMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) { "Exporting media ($attachmentsProcessed/$attachmentCount)..." } else { "Exporting messages ($messagesProcessed/$messageCount)..." @@ -796,8 +804,11 @@ class ConversationViewModel( ) _plaintextExportState.value = when { - plaintextExportCancelled.get() -> PlaintextExportState.Cancelled - success -> PlaintextExportState.Complete + plaintextExportCancelled.get() -> { + outputFile.delete() + PlaintextExportState.Cancelled + } + success -> PlaintextExportState.Complete(outputFile) else -> PlaintextExportState.Failed } } @@ -821,7 +832,7 @@ class ConversationViewModel( data object None : PlaintextExportState data object Preparing : PlaintextExportState data class InProgress(val percent: Int, val status: String) : PlaintextExportState - data object Complete : PlaintextExportState + data class Complete(val zipFile: File) : PlaintextExportState data object Failed : PlaintextExportState data object Cancelled : PlaintextExportState }