Switch the labs plaintext export to share a single zip.

This commit is contained in:
Greyson Parrelli
2026-04-13 16:43:10 -04:00
committed by jeffrey-signal
parent 086883e565
commit 78940ffc17
3 changed files with 71 additions and 93 deletions

View File

@@ -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<PendingAttachment>()
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")

View File

@@ -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<Intent>
private lateinit var plaintextExportDirectoryLauncher: ActivityResultLauncher<Uri?>
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()

View File

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