Give a media/no-media choice in labs plaintext export.

This commit is contained in:
Greyson Parrelli
2026-04-07 12:13:12 -04:00
parent c47adb7482
commit 4c76cb682e
4 changed files with 97 additions and 51 deletions

View File

@@ -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<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
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<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
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<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
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()
}

View File

@@ -552,6 +552,7 @@ class ConversationFragment :
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
@@ -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()
}
}

View File

@@ -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)..."