Add the ability to do an export of a single chat.

This commit is contained in:
Greyson Parrelli
2026-03-16 13:57:51 -04:00
committed by Michelle Tang
parent 2f41d15a41
commit 2b163a9acd
12 changed files with 1469 additions and 22 deletions

View File

@@ -61,13 +61,18 @@ class SignalProgressDialog private constructor(
message: CharSequence? = null,
indeterminate: Boolean = false,
cancelable: Boolean = false,
cancelListener: DialogInterface.OnCancelListener? = null
cancelListener: DialogInterface.OnCancelListener? = null,
negativeButtonText: CharSequence? = null,
negativeButtonListener: DialogInterface.OnClickListener? = null
): SignalProgressDialog {
val builder = MaterialAlertDialogBuilder(context).apply {
setTitle(null)
setMessage(null)
setCancelable(cancelable)
setOnCancelListener(cancelListener)
if (negativeButtonText != null) {
setNegativeButton(negativeButtonText, negativeButtonListener)
}
}
val customView = LayoutInflater.from(context).inflate(R.layout.signal_progress_dialog, null) as ConstraintLayout

View File

@@ -18,6 +18,7 @@ import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
/**
* Delegate object for managing the conversation options menu
@@ -163,6 +164,12 @@ internal object ConversationOptionsMenu {
hideMenuItem(menu, R.id.menu_add_shortcut)
}
if (RemoteConfig.internalUser) {
menu.findItem(R.id.menu_export)?.title = menu.findItem(R.id.menu_export)?.title.toString() + " (Internal Only)"
} else {
hideMenuItem(menu, R.id.menu_export)
}
if (isActiveV2Group) {
hideMenuItem(menu, R.id.menu_mute_notifications)
hideMenuItem(menu, R.id.menu_conversation_settings)
@@ -210,6 +217,7 @@ internal object ConversationOptionsMenu {
R.id.menu_unmute_notifications -> callback.handleUnmuteNotifications()
R.id.menu_conversation_settings -> callback.handleConversationSettings()
R.id.menu_expiring_messages_off, R.id.menu_expiring_messages -> callback.handleSelectMessageExpiration()
R.id.menu_export -> callback.handleExportChat()
R.id.menu_create_bubble -> callback.handleCreateBubble()
androidx.appcompat.R.id.home -> callback.handleGoHome()
R.id.menu_block -> callback.handleBlock()
@@ -289,5 +297,6 @@ internal object ConversationOptionsMenu {
fun handleReportSpam()
fun handleMessageRequestAccept()
fun handleDeleteConversation()
fun handleExportChat()
}
}

View File

@@ -0,0 +1,477 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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
import org.thoughtcrime.securesms.database.MentionUtil
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
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.BufferedWriter
import java.io.IOException
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
/**
* Exports a conversation thread as user-friendly plaintext with attachments.
*/
object PlaintextExportRepository {
private val TAG = Log.tag(PlaintextExportRepository::class.java)
private const val BATCH_SIZE = 500
fun export(
context: Context,
threadId: Long,
directoryUri: Uri,
chatName: String,
progressListener: ProgressListener,
cancellationSignal: CancellationSignal
): Boolean {
val eventTimer = EventTimer()
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 = chatDir.createDirectory("media") ?: run {
Log.w(TAG, "Could not create media directory")
return false
}
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 ->
writer.write("Chat export: $chatName")
writer.newLine()
writer.write("Exported on: ${dateFormat.format(Date())}")
writer.newLine()
writer.write("=".repeat(60))
writer.newLine()
writer.newLine()
val extraDataTimer = ParallelEventTimer()
// Messages
MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId, dateReceiveOrderBy = "ASC")).use { reader ->
while (true) {
if (cancellationSignal.isCancelled()) return false
val batch = readBatch(reader)
if (batch.isEmpty()) break
val extraData = fetchExtraData(batch, extraDataTimer)
eventTimer.emit("extra-data")
for (message in batch) {
if (cancellationSignal.isCancelled()) return false
writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments)
writer.newLine()
messagesProcessed++
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount)
}
eventTimer.emit("messages")
}
}
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.
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)
}
}
} 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}")
return true
}
private fun readBatch(reader: MessageTable.MmsReader): List<MmsMessageRecord> {
val batch = ArrayList<MmsMessageRecord>(BATCH_SIZE)
for (i in 0 until BATCH_SIZE) {
val record = reader.getNext() ?: break
if (record is MmsMessageRecord) {
batch.add(record)
}
}
return batch
}
private fun fetchExtraData(batch: List<MmsMessageRecord>, extraDataTimer: ParallelEventTimer): ExtraMessageData {
val messageIds = batch.map { it.id }
val executor = SignalExecutors.BOUNDED
val attachmentsFuture = executor.submitTyped {
extraDataTimer.timeEvent("attachments") {
SignalDatabase.attachments.getAttachmentsForMessages(messageIds)
}
}
val mentionsFuture = executor.submitTyped {
extraDataTimer.timeEvent("mentions") {
SignalDatabase.mentions.getMentionsForMessages(messageIds)
}
}
val pollsFuture = executor.submitTyped {
extraDataTimer.timeEvent("polls") {
SignalDatabase.polls.getPollsForMessages(messageIds)
}
}
return ExtraMessageData(
attachmentsById = attachmentsFuture.get(),
mentionsById = mentionsFuture.get(),
pollsById = pollsFuture.get()
)
}
@VisibleForTesting
internal fun getExportStats(threadId: Long): ExportStats {
val messageCount = SignalDatabase.messages.getMessageCountForThread(threadId)
val attachmentCount = SignalDatabase.attachments.getPlaintextExportableAttachmentCountForThread(threadId)
return ExportStats(messageCount, attachmentCount)
}
@VisibleForTesting
internal fun BufferedWriter.writeMessage(
context: Context,
message: MmsMessageRecord,
extraData: ExtraMessageData,
dateFormat: SimpleDateFormat,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
) {
val timestamp = dateFormat.format(Date(message.dateSent))
if (message.isUpdate) {
this.writeUpdateMessage(context, message, timestamp)
return
}
val sender = getSenderName(context, message)
val prefix = "[$timestamp] $sender: "
if (message.isRemoteDelete) {
this.write("$prefix(This message was deleted)")
this.newLine()
return
}
if (message.isViewOnce) {
this.write("$prefix(View-once media)")
this.newLine()
return
}
val poll = extraData.pollsById[message.id]
if (poll != null) {
this.writePoll(prefix, poll)
return
}
val attachments = extraData.attachmentsById[message.id] ?: emptyList()
val mainAttachments = attachments.filter { it.hasData && !it.quote }
val stickerAttachment = mainAttachments.find { it.stickerLocator != null }
val hasQuote = message.quote != null
if (hasQuote) {
this.writeQuote(context, message.quote!!, timestamp, sender)
}
if (stickerAttachment != null) {
this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments)
return
}
val mentions = extraData.mentionsById[message.id] ?: emptyList()
val body = resolveBody(context, message.body, mentions)
if (!body.isNullOrEmpty()) {
if (!hasQuote) {
this.write("$prefix$body")
} else {
this.write(body)
}
this.newLine()
} else if (!hasQuote && mainAttachments.isEmpty()) {
this.write(prefix)
this.newLine()
return
}
val wrotePrefix = !body.isNullOrEmpty() || hasQuote
this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments)
}
private fun BufferedWriter.writeUpdateMessage(context: Context, message: MmsMessageRecord, timestamp: String) {
this.write("--- ${formatUpdateMessage(context, message, timestamp)} ---")
this.newLine()
}
private fun BufferedWriter.writePoll(prefix: String, poll: PollRecord) {
this.write("$prefix(Poll) ${poll.question}")
this.newLine()
for (option in poll.pollOptions) {
val voteCount = option.voters.size
val voteSuffix = if (voteCount == 1) "vote" else "votes"
this.write(" - ${option.text} ($voteCount $voteSuffix)")
this.newLine()
}
if (poll.hasEnded) {
this.write(" (Poll ended)")
this.newLine()
}
}
private fun BufferedWriter.writeQuote(context: Context, quote: Quote, timestamp: String, sender: String) {
val quoteAuthor = Recipient.resolved(quote.author).getDisplayName(context)
val quoteText = quote.displayText?.toString()?.ifEmpty { null } ?: "(media)"
this.write("[$timestamp] $sender:")
this.newLine()
this.write("> Quoting $quoteAuthor:")
this.newLine()
for (line in quoteText.lines()) {
this.write("> $line")
this.newLine()
}
}
private fun BufferedWriter.writeSticker(
stickerAttachment: DatabaseAttachment,
prefix: String,
hasQuote: Boolean,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
) {
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]")
this.newLine()
}
private fun BufferedWriter.writeAttachments(
attachments: List<DatabaseAttachment>,
prefix: String,
wrotePrefix: Boolean,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
) {
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")
} else {
this.write("[$label: media/$exportedName]")
}
this.newLine()
}
}
private fun resolveBody(context: Context, body: String?, mentions: List<Mention>): String? {
if (mentions.isNotEmpty() && !body.isNullOrEmpty()) {
return MentionUtil.updateBodyAndMentionsWithDisplayNames(context, body, mentions).body?.toString()
}
return body
}
@VisibleForTesting
internal fun getAttachmentLabel(attachment: DatabaseAttachment): String {
val contentType = attachment.contentType ?: return "Attachment"
return when {
MediaUtil.isAudioType(contentType) && attachment.voiceNote -> "Voice message"
MediaUtil.isAudioType(contentType) -> "Audio"
MediaUtil.isVideoType(contentType) && attachment.videoGif -> "GIF"
MediaUtil.isVideoType(contentType) -> "Video"
MediaUtil.isImageType(contentType) -> "Image"
else -> "Document"
}
}
@VisibleForTesting
internal fun getSenderName(context: Context, message: MmsMessageRecord): String {
return if (message.isOutgoing) {
"You"
} else {
message.fromRecipient.getDisplayName(context)
}
}
@VisibleForTesting
internal fun formatUpdateMessage(context: Context, message: MmsMessageRecord, timestamp: String): String {
return when {
message.isGroupUpdate -> "$timestamp Group updated"
message.isGroupQuit -> "$timestamp ${getSenderName(context, message)} left the group"
message.isExpirationTimerUpdate -> "$timestamp Disappearing messages timer updated"
message.isIdentityUpdate -> "$timestamp Safety number changed"
message.isIdentityVerified -> "$timestamp Safety number verified"
message.isIdentityDefault -> "$timestamp Safety number verification reset"
message.isProfileChange -> "$timestamp Profile updated"
message.isChangeNumber -> "$timestamp Phone number changed"
message.isCallLog -> formatCallMessage(context, message, timestamp)
message.isJoined -> "$timestamp ${getSenderName(context, message)} joined Signal"
message.isGroupV1MigrationEvent -> "$timestamp Group upgraded to new group type"
message.isPaymentNotification -> "$timestamp Payment sent/received"
else -> "$timestamp System message"
}
}
@VisibleForTesting
internal fun formatCallMessage(context: Context, message: MmsMessageRecord, timestamp: String): String {
return when {
message.isIncomingAudioCall -> "$timestamp Incoming voice call"
message.isIncomingVideoCall -> "$timestamp Incoming video call"
message.isOutgoingAudioCall -> "$timestamp Outgoing voice call"
message.isOutgoingVideoCall -> "$timestamp Outgoing video call"
message.isMissedAudioCall -> "$timestamp Missed voice call"
message.isMissedVideoCall -> "$timestamp Missed video call"
message.isGroupCall -> "$timestamp Group call"
else -> "$timestamp Call"
}
}
@VisibleForTesting
internal fun buildAttachmentFileName(attachment: DatabaseAttachment, dateFormat: SimpleDateFormat): String {
val date = dateFormat.format(Date(attachment.uploadTimestamp.takeIf { it > 0 } ?: System.currentTimeMillis()))
val id = attachment.attachmentId.id
val extension = getExtension(attachment)
return "$date-$id.$extension"
}
@VisibleForTesting
internal fun getExtension(attachment: DatabaseAttachment): String {
val fromFileName = attachment.fileName?.substringAfterLast('.', "")?.takeIf { it.isNotEmpty() && it.length <= 10 }
if (fromFileName != null) return fromFileName
val fromMime = attachment.contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
if (fromMime != null) return fromMime
return "bin"
}
@VisibleForTesting
internal fun sanitizeFileName(name: String): String {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim().take(100)
}
private fun <T> ExecutorService.submitTyped(callable: Callable<T>): Future<T> {
return this.submit(callable)
}
data class ExportStats(val messageCount: Int, val attachmentCount: Int)
@VisibleForTesting
data class PendingAttachment(
val attachment: DatabaseAttachment,
val exportedName: String
)
@VisibleForTesting
internal data class ExtraMessageData(
val attachmentsById: Map<Long, List<DatabaseAttachment>>,
val mentionsById: Map<Long, List<Mention>>,
val pollsById: Map<Long, PollRecord>
)
fun interface ProgressListener {
fun onProgress(messagesProcessed: Int, messageCount: Int, attachmentsProcessed: Int, attachmentCount: Int)
}
fun interface CancellationSignal {
fun isCancelled(): Boolean
}
}

View File

@@ -37,6 +37,7 @@ import android.view.View
import android.view.View.OnFocusChangeListener
import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.view.WindowManager
import android.view.animation.AnimationUtils
import android.view.inputmethod.EditorInfo
import android.widget.ImageButton
@@ -104,6 +105,7 @@ 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
@@ -147,6 +149,7 @@ import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
import org.thoughtcrime.securesms.components.SendButton
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.compose.ActionModeTopBarView
import org.thoughtcrime.securesms.components.compose.DeleteSyncEducationDialog
@@ -548,6 +551,7 @@ 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 lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
private lateinit var adapter: ConversationAdapterV2
@@ -677,6 +681,7 @@ class ConversationFragment :
presentStoryRing()
observeConversationThread()
observePlaintextExportState()
viewModel
.inputReadyState
@@ -1543,9 +1548,79 @@ 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)
}
}
conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks())
}
private fun observePlaintextExportState() {
var progressDialog: SignalProgressDialog? = null
lifecycleScope.launch {
viewModel.plaintextExportState.collectLatest { state ->
val exporting = state is ConversationViewModel.PlaintextExportState.Preparing || state is ConversationViewModel.PlaintextExportState.InProgress
if (exporting) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
when (state) {
is ConversationViewModel.PlaintextExportState.None -> {
progressDialog?.dismiss()
progressDialog = null
}
is ConversationViewModel.PlaintextExportState.Preparing -> {
progressDialog = SignalProgressDialog.show(
context = requireContext(),
title = getString(R.string.conversation_export__exporting),
message = getString(R.string.conversation_export__preparing),
indeterminate = true,
cancelable = false,
negativeButtonText = getString(android.R.string.cancel),
negativeButtonListener = { _, _ -> viewModel.cancelExport() }
)
}
is ConversationViewModel.PlaintextExportState.InProgress -> {
progressDialog?.let {
it.isIndeterminate = false
it.progress = state.percent
it.setMessage(state.status)
}
}
is ConversationViewModel.PlaintextExportState.Complete -> {
progressDialog?.dismiss()
progressDialog = null
toast(R.string.conversation_export__export_complete, toastDuration = Toast.LENGTH_LONG)
viewModel.clearPlaintextExportState()
}
is ConversationViewModel.PlaintextExportState.Failed -> {
progressDialog?.dismiss()
progressDialog = null
toast(R.string.conversation_export__export_failed, toastDuration = Toast.LENGTH_LONG)
viewModel.clearPlaintextExportState()
}
is ConversationViewModel.PlaintextExportState.Cancelled -> {
progressDialog?.dismiss()
progressDialog = null
toast(R.string.conversation_export__export_cancelled, toastDuration = Toast.LENGTH_SHORT)
viewModel.clearPlaintextExportState()
}
}
}
}
}
private fun onRecipientChanged(recipient: Recipient) {
presentWallpaper(recipient.wallpaper)
presentConversationTitle(recipient)
@@ -4090,6 +4165,10 @@ class ConversationFragment :
override fun handleDeleteConversation() {
onDeleteConversation()
}
override fun handleExportChat() {
plaintextExportDirectoryLauncher.launch(null)
}
}
private inner class OnReactionsSelectedListener : ConversationReactionOverlay.OnReactionSelectedListener {

View File

@@ -46,6 +46,7 @@ import org.signal.core.models.ServiceId
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.banner.Banner
import org.thoughtcrime.securesms.banner.banners.BubbleOptOutBanner
import org.thoughtcrime.securesms.banner.banners.GroupsV1MigrationSuggestionsBanner
@@ -58,6 +59,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ScheduledMessagesRepository
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.plaintext.PlaintextExportRepository
import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
import org.thoughtcrime.securesms.database.DatabaseObserver
@@ -210,6 +212,11 @@ class ConversationViewModel(
private val internalPinnedMessages = MutableStateFlow<List<ConversationMessage>>(emptyList())
val pinnedMessages: StateFlow<List<ConversationMessage>> = internalPinnedMessages
private val _plaintextExportState = MutableStateFlow<PlaintextExportState>(PlaintextExportState.None)
val plaintextExportState: StateFlow<PlaintextExportState> = _plaintextExportState
private val plaintextExportCancelled = java.util.concurrent.atomic.AtomicBoolean(false)
init {
disposables += recipient
.subscribeBy {
@@ -723,6 +730,60 @@ class ConversationViewModel(
}
}
fun startPlaintextExport(context: Context, directoryUri: Uri) {
val recipient = recipientSnapshot ?: return
val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context)
plaintextExportCancelled.set(false)
_plaintextExportState.value = PlaintextExportState.Preparing
viewModelScope.launch(Dispatchers.IO) {
val success = PlaintextExportRepository.export(
context = context,
threadId = threadId,
directoryUri = directoryUri,
chatName = chatName,
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 status = if (attachmentsProcessed > 0 || messagesProcessed >= messageCount) {
"Exporting media ($attachmentsProcessed/$attachmentCount)..."
} else {
"Exporting messages ($messagesProcessed/$messageCount)..."
}
_plaintextExportState.value = PlaintextExportState.InProgress(percent = percent, status = status)
},
cancellationSignal = { plaintextExportCancelled.get() }
)
_plaintextExportState.value = when {
plaintextExportCancelled.get() -> PlaintextExportState.Cancelled
success -> PlaintextExportState.Complete
else -> PlaintextExportState.Failed
}
}
}
fun cancelExport() {
plaintextExportCancelled.set(true)
}
fun clearPlaintextExportState() {
_plaintextExportState.value = PlaintextExportState.None
}
sealed interface PlaintextExportState {
data object None : PlaintextExportState
data object Preparing : PlaintextExportState
data class InProgress(val percent: Int, val status: String) : PlaintextExportState
data object Complete : PlaintextExportState
data object Failed : PlaintextExportState
data object Cancelled : PlaintextExportState
}
data class BackPressedState(
val isReactionDelegateShowing: Boolean = false,
val isSearchRequested: Boolean = false,

View File

@@ -84,11 +84,6 @@ import org.thoughtcrime.securesms.crypto.AttachmentSecret
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.COPY_PENDING
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.FINISHED
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.NONE
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.PERMANENT_FAILURE
import org.thoughtcrime.securesms.database.AttachmentTable.ArchiveTransferState.UPLOAD_IN_PROGRESS
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_FILE
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.DATA_HASH_END
import org.thoughtcrime.securesms.database.AttachmentTable.Companion.PREUPLOAD_MESSAGE_ID
@@ -496,6 +491,32 @@ class AttachmentTable(
.flatten()
}
/**
* Returns the number of attachments that will be exported for a plaintext export of a given thread.
* Used for estimating progress.
*/
fun getPlaintextExportableAttachmentCountForThread(threadId: Long): Int {
return readableDatabase.rawQuery(
"""
SELECT COUNT(*)
FROM $TABLE_NAME
INNER JOIN ${MessageTable.TABLE_NAME} ON $TABLE_NAME.$MESSAGE_ID = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
WHERE ${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = ?
AND ${MessageTable.TABLE_NAME}.${MessageTable.STORY_TYPE} = 0
AND ${MessageTable.TABLE_NAME}.${MessageTable.PARENT_STORY_ID} <= 0
AND ${MessageTable.TABLE_NAME}.${MessageTable.SCHEDULED_DATE} = -1
AND ${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL
AND ${MessageTable.TABLE_NAME}.${MessageTable.VIEW_ONCE} = 0
AND ${MessageTable.TABLE_NAME}.${MessageTable.DELETED_BY} IS NULL
AND $TABLE_NAME.$DATA_FILE IS NOT NULL
AND $TABLE_NAME.$QUOTE = 0
""".trimIndent(),
arrayOf(threadId.toString())
).use { cursor ->
if (cursor.moveToFirst()) cursor.getInt(0) else 0
}
}
fun getAttachmentsForMessagesArchive(mmsIds: Collection<Long>): Map<Long, List<DatabaseAttachment>> {
if (mmsIds.isEmpty()) {
return emptyMap()

View File

@@ -5372,14 +5372,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
* A cursor containing all of the messages in a given thread, in the proper order, respecting offset/limit.
* This does *not* have attachments in it.
*/
fun getConversation(threadId: Long, offset: Long, limit: Long): Cursor {
fun getConversation(threadId: Long, offset: Long = 0, limit: Long = 0, dateReceiveOrderBy: String = "DESC"): Cursor {
val limitStr: String = if (limit > 0 || offset > 0) "$offset, $limit" else ""
return readableDatabase
.select(*MMS_PROJECTION)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, 0, 0, -1)
.orderBy("$DATE_RECEIVED DESC")
.orderBy("$DATE_RECEIVED $dateReceiveOrderBy")
.limit(limitStr)
.run()
}