mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-19 16:19:33 +01:00
Add the ability to do an export of a single chat.
This commit is contained in:
committed by
Michelle Tang
parent
2f41d15a41
commit
2b163a9acd
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user