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()
}

View File

@@ -5,13 +5,16 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="24dp"
android:paddingBottom="24dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress_dialog_progressbar"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginTop="62dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
@@ -21,9 +24,10 @@
<TextView
android:id="@+id/progress_dialog_title"
style="@style/Signal.Text.BodyMedium"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginTop="16dp"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progress_dialog_progressbar" />
@@ -31,13 +35,11 @@
<TextView
android:id="@+id/progress_dialog_message"
style="@style/Signal.Text.Caption"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="38dp"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_marginTop="4dp"
android:textAlignment="center"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progress_dialog_title"
android:textAlignment="center" />
</androidx.constraintlayout.widget.ConstraintLayout>
app:layout_constraintTop_toBottomOf="@id/progress_dialog_title" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -25,6 +25,10 @@
android:id="@+id/menu_create_bubble"
android:title="@string/conversation__menu_create_bubble" />
<item
android:id="@+id/menu_export"
android:title="@string/conversation__menu_export" />
<item
android:id="@+id/menu_format_text_submenu"
android:title="@string/conversation__menu_format_text"

View File

@@ -4651,6 +4651,7 @@
<string name="conversation__menu_view_all_media">All media</string>
<string name="conversation__menu_conversation_settings">Chat settings</string>
<string name="conversation__menu_add_shortcut">Add to home screen</string>
<string name="conversation__menu_export">Export</string>
<string name="conversation__menu_create_bubble">Create bubble</string>
<!-- Overflow menu option that allows formatting of text -->
<string name="conversation__menu_format_text">Format text</string>
@@ -4660,6 +4661,13 @@
<!-- conversation_callable_insecure -->
<string name="conversation_add_to_contacts__menu_add_to_contacts">Add to contacts</string>
<!-- conversation export -->
<string name="conversation_export__exporting">Exporting chat…</string>
<string name="conversation_export__export_complete">Chat exported successfully</string>
<string name="conversation_export__export_failed">Export failed</string>
<string name="conversation_export__export_cancelled">Export cancelled</string>
<string name="conversation_export__preparing">Preparing export…</string>
<!-- conversation scheduled messages bar -->
<!-- Label for button in a banner to show all messages currently scheduled -->

View File

@@ -0,0 +1,777 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.plaintext
import android.app.Application
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.conversation.plaintext.PlaintextExportRepository.PendingAttachment
import org.thoughtcrime.securesms.database.FakeMessageRecords
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.polls.Voter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.util.MediaUtil
import java.io.BufferedWriter
import java.io.StringWriter
import java.text.SimpleDateFormat
import java.util.Locale
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class PlaintextExportRepositoryTest {
@get:Rule
val appDependencies = MockAppDependenciesRule()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val attachmentDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private lateinit var context: android.content.Context
private val mockRecipient: Recipient = mockk(relaxed = true)
@Before
fun setUp() {
context = mockk(relaxed = true)
every { mockRecipient.getDisplayName(any()) } returns "Alice"
val mockLiveRecipient = mockk<org.thoughtcrime.securesms.recipients.LiveRecipient>(relaxed = true)
every { mockLiveRecipient.get() } returns mockRecipient
every { mockRecipient.live() } returns mockLiveRecipient
mockkObject(Recipient)
every { Recipient.resolved(any()) } returns mockRecipient
}
@After
fun tearDown() {
unmockkObject(Recipient)
}
// ==================== writeMessage tests ====================
@Test
fun `writeMessage with plain incoming text message`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "Hello, world!",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient,
mailbox = MessageTypes.BASE_INBOX_TYPE
)
val output = renderMessage(message)
assertTrue(output.contains("] Alice: Hello, world!"))
}
@Test
fun `writeMessage with outgoing text message shows You as sender`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "Hello from me",
dateSent = 1710500000000L,
mailbox = MessageTypes.BASE_SENT_TYPE
)
val output = renderMessage(message)
assertTrue(output.contains("] You: Hello from me"))
}
@Test
fun `writeMessage with deleted message`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient,
mailbox = MessageTypes.BASE_INBOX_TYPE,
deletedBy = RecipientId.from(1)
)
val output = renderMessage(message)
assertTrue(output.contains("Alice: (This message was deleted)"))
}
@Test
fun `writeMessage with view-once message`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient,
viewOnce = true
)
val output = renderMessage(message)
assertTrue(output.contains("Alice: (View-once media)"))
}
@Test
fun `writeMessage with quote includes quote block`() {
val quoteRecipientId = RecipientId.from(42)
every { Recipient.resolved(quoteRecipientId).getDisplayName(any()) } returns "Bob"
val quote = Quote(
1000L,
quoteRecipientId,
"Original message text",
false,
SlideDeck(),
emptyList(),
QuoteModel.Type.NORMAL
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "My reply",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient,
quote = quote
)
val output = renderMessage(message)
assertTrue("Should contain quoting header", output.contains("> Quoting Bob:"))
assertTrue("Should contain quoted text", output.contains("> Original message text"))
assertTrue("Should contain reply body", output.contains("My reply"))
}
@Test
fun `writeMessage with quote and no body shows media placeholder`() {
val quoteRecipientId = RecipientId.from(42)
every { Recipient.resolved(quoteRecipientId).getDisplayName(any()) } returns "Bob"
val quote = Quote(
1000L,
quoteRecipientId,
null,
false,
SlideDeck(),
emptyList(),
QuoteModel.Type.NORMAL
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "Replying to media",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient,
quote = quote
)
val output = renderMessage(message)
assertTrue("Should contain media placeholder for null quote text", output.contains("> (media)"))
}
@Test
fun `writeMessage with multiline quote preserves lines`() {
val quoteRecipientId = RecipientId.from(42)
every { Recipient.resolved(quoteRecipientId).getDisplayName(any()) } returns "Bob"
val quote = Quote(
1000L,
quoteRecipientId,
"Line one\nLine two\nLine three",
false,
SlideDeck(),
emptyList(),
QuoteModel.Type.NORMAL
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "My reply",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient,
quote = quote
)
val output = renderMessage(message)
assertTrue(output.contains("> Line one"))
assertTrue(output.contains("> Line two"))
assertTrue(output.contains("> Line three"))
}
// ==================== Poll tests ====================
@Test
fun `writeMessage with active poll`() {
val poll = PollRecord(
id = 1,
question = "Favorite color?",
pollOptions = listOf(
PollOption(1, "Red", listOf(Voter(1, 1), Voter(2, 1))),
PollOption(2, "Blue", listOf(Voter(3, 1)))
),
allowMultipleVotes = false,
hasEnded = false,
authorId = 1,
messageId = 1
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val output = renderMessage(message, poll = poll)
assertTrue(output.contains("(Poll) Favorite color?"))
assertTrue(output.contains(" - Red (2 votes)"))
assertTrue(output.contains(" - Blue (1 vote)"))
assertTrue("Active poll should not show ended", !output.contains("(Poll ended)"))
}
@Test
fun `writeMessage with ended poll shows ended marker`() {
val poll = PollRecord(
id = 1,
question = "Where to eat?",
pollOptions = listOf(
PollOption(1, "Pizza", listOf(Voter(1, 1)))
),
allowMultipleVotes = false,
hasEnded = true,
authorId = 1,
messageId = 1
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
mailbox = MessageTypes.BASE_SENT_TYPE
)
val output = renderMessage(message, poll = poll)
assertTrue(output.contains("(Poll) Where to eat?"))
assertTrue(output.contains("(Poll ended)"))
}
@Test
fun `writeMessage with poll with zero votes`() {
val poll = PollRecord(
id = 1,
question = "New poll",
pollOptions = listOf(
PollOption(1, "Option A", emptyList()),
PollOption(2, "Option B", emptyList())
),
allowMultipleVotes = false,
hasEnded = false,
authorId = 1,
messageId = 1
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val output = renderMessage(message, poll = poll)
assertTrue(output.contains(" - Option A (0 votes)"))
assertTrue(output.contains(" - Option B (0 votes)"))
}
// ==================== Attachment tests ====================
@Test
fun `writeMessage with image attachment`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(100),
contentType = MediaUtil.IMAGE_JPEG,
hasData = true,
uploadTimestamp = 1710500000000L
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val pending = mutableListOf<PendingAttachment>()
val output = renderMessage(message, listOf(attachment), pending)
assertTrue("Should label as Image", output.contains("[Image: media/"))
assertEquals("Should queue one attachment", 1, pending.size)
}
@Test
fun `writeMessage with voice note attachment`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(101),
contentType = "audio/aac",
hasData = true,
voiceNote = true,
uploadTimestamp = 1710500000000L
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val pending = mutableListOf<PendingAttachment>()
val output = renderMessage(message, listOf(attachment), pending)
assertTrue("Should label as Voice message", output.contains("[Voice message: media/"))
}
@Test
fun `writeMessage with video gif attachment`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(102),
contentType = "video/mp4",
hasData = true,
videoGif = true,
uploadTimestamp = 1710500000000L
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val pending = mutableListOf<PendingAttachment>()
val output = renderMessage(message, listOf(attachment), pending)
assertTrue("Should label as GIF", output.contains("[GIF: media/"))
}
@Test
fun `writeMessage with body and attachment includes both`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(103),
contentType = MediaUtil.IMAGE_JPEG,
hasData = true,
uploadTimestamp = 1710500000000L
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "Check out this photo",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val output = renderMessage(message, listOf(attachment))
assertTrue("Should contain body", output.contains("Alice: Check out this photo"))
assertTrue("Should contain attachment", output.contains("[Image: media/"))
}
@Test
fun `writeMessage with captioned attachment includes caption`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(104),
contentType = MediaUtil.IMAGE_JPEG,
hasData = true,
caption = "A lovely sunset",
uploadTimestamp = 1710500000000L
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val output = renderMessage(message, listOf(attachment))
assertTrue("Should include caption", output.contains("] A lovely sunset"))
}
@Test
fun `writeMessage with sticker attachment`() {
val stickerLocator = StickerLocator("pack1", "key1", 1, "\uD83D\uDE00")
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(105),
contentType = "image/webp",
hasData = true,
stickerLocator = stickerLocator,
uploadTimestamp = 1710500000000L
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val pending = mutableListOf<PendingAttachment>()
val output = renderMessage(message, listOf(attachment), pending)
assertTrue("Should show sticker format", output.contains("(Sticker) \uD83D\uDE00 [See: media/"))
assertEquals("Should queue sticker for export", 1, pending.size)
}
@Test
fun `writeMessage filters out quote attachments`() {
val quoteAttachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(200),
contentType = MediaUtil.IMAGE_JPEG,
hasData = true,
quote = true,
uploadTimestamp = 1710500000000L
)
val mainAttachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(201),
contentType = MediaUtil.IMAGE_JPEG,
hasData = true,
uploadTimestamp = 1710500000000L
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val pending = mutableListOf<PendingAttachment>()
renderMessage(message, listOf(quoteAttachment, mainAttachment), pending)
assertEquals("Should only queue the non-quote attachment", 1, pending.size)
assertEquals(AttachmentId(201), pending[0].attachment.attachmentId)
}
@Test
fun `writeMessage with multiple attachments queues all`() {
val att1 = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(301),
contentType = MediaUtil.IMAGE_JPEG,
hasData = true,
uploadTimestamp = 1710500000000L
)
val att2 = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(302),
contentType = "video/mp4",
hasData = true,
uploadTimestamp = 1710500000000L
)
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
conversationRecipient = mockRecipient
)
val pending = mutableListOf<PendingAttachment>()
val output = renderMessage(message, listOf(att1, att2), pending)
assertEquals("Should queue both attachments", 2, pending.size)
assertTrue("Should have Image label", output.contains("[Image:"))
assertTrue("Should have Video label", output.contains("[Video:"))
}
// ==================== System message tests ====================
@Test
fun `writeMessage with group update`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
mailbox = MessageTypes.BASE_INBOX_TYPE or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.GROUP_UPDATE_BIT
)
val output = renderMessage(message)
assertTrue(output.contains("--- "))
assertTrue(output.contains("Group updated"))
assertTrue(output.contains(" ---"))
}
@Test
fun `writeMessage with expiration timer update`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
mailbox = MessageTypes.BASE_INBOX_TYPE or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
)
val output = renderMessage(message)
assertTrue(output.contains("Disappearing messages timer updated"))
}
@Test
fun `writeMessage with incoming audio call`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
mailbox = MessageTypes.INCOMING_AUDIO_CALL_TYPE.toLong()
)
val output = renderMessage(message)
assertTrue(output.contains("Incoming voice call"))
}
@Test
fun `writeMessage with missed video call`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
mailbox = MessageTypes.MISSED_VIDEO_CALL_TYPE.toLong()
)
val output = renderMessage(message)
assertTrue(output.contains("Missed video call"))
}
@Test
fun `writeMessage with profile change`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
id = 1,
body = "",
dateSent = 1710500000000L,
mailbox = MessageTypes.PROFILE_CHANGE_TYPE.toLong()
)
val output = renderMessage(message)
assertTrue(output.contains("Profile updated"))
}
// ==================== getAttachmentLabel tests ====================
@Test
fun `getAttachmentLabel returns Image for image types`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(contentType = MediaUtil.IMAGE_JPEG)
assertEquals("Image", PlaintextExportRepository.getAttachmentLabel(attachment))
}
@Test
fun `getAttachmentLabel returns Video for video types`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(contentType = "video/mp4")
assertEquals("Video", PlaintextExportRepository.getAttachmentLabel(attachment))
}
@Test
fun `getAttachmentLabel returns GIF for video gif`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(contentType = "video/mp4", videoGif = true)
assertEquals("GIF", PlaintextExportRepository.getAttachmentLabel(attachment))
}
@Test
fun `getAttachmentLabel returns Audio for audio types`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(contentType = "audio/mpeg")
assertEquals("Audio", PlaintextExportRepository.getAttachmentLabel(attachment))
}
@Test
fun `getAttachmentLabel returns Voice message for voice note`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(contentType = "audio/aac", voiceNote = true)
assertEquals("Voice message", PlaintextExportRepository.getAttachmentLabel(attachment))
}
@Test
fun `getAttachmentLabel returns Document for application types`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(contentType = "application/pdf")
assertEquals("Document", PlaintextExportRepository.getAttachmentLabel(attachment))
}
// ==================== buildAttachmentFileName tests ====================
@Test
fun `buildAttachmentFileName uses extension from file name`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(42),
fileName = "photo.png",
uploadTimestamp = 1710500000000L
)
val name = PlaintextExportRepository.buildAttachmentFileName(attachment, attachmentDateFormat)
assertTrue("Should end with .png", name.endsWith(".png"))
assertTrue("Should contain attachment id", name.contains("-42."))
assertTrue("Should start with date", name.startsWith("2024-03-15"))
}
@Test
fun `buildAttachmentFileName falls back to mime type extension`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(43),
contentType = MediaUtil.IMAGE_JPEG,
fileName = "",
uploadTimestamp = 1710500000000L
)
val name = PlaintextExportRepository.buildAttachmentFileName(attachment, attachmentDateFormat)
// MimeTypeMap may not be populated in Robolectric, so it may fall back to bin
assertTrue("Should contain attachment id", name.contains("-43."))
}
@Test
fun `buildAttachmentFileName falls back to bin`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
attachmentId = AttachmentId(44),
contentType = "application/x-unknown-type-for-test",
fileName = "",
uploadTimestamp = 1710500000000L
)
val name = PlaintextExportRepository.buildAttachmentFileName(attachment, attachmentDateFormat)
assertTrue("Should end with .bin for unknown types", name.endsWith(".bin"))
}
// ==================== getExtension tests ====================
@Test
fun `getExtension prefers file extension from fileName`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(fileName = "document.xlsx")
assertEquals("xlsx", PlaintextExportRepository.getExtension(attachment))
}
@Test
fun `getExtension ignores overly long extensions`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
fileName = "file.thisisaverylongextension",
contentType = "application/x-unknown-for-test"
)
// Extension is > 10 chars, should fall through
assertEquals("bin", PlaintextExportRepository.getExtension(attachment))
}
@Test
fun `getExtension falls back to bin when no info available`() {
val attachment = FakeMessageRecords.buildDatabaseAttachment(
fileName = "",
contentType = "application/x-totally-unknown-for-test"
)
assertEquals("bin", PlaintextExportRepository.getExtension(attachment))
}
// ==================== sanitizeFileName tests ====================
@Test
fun `sanitizeFileName replaces special characters`() {
assertEquals("hello_world", PlaintextExportRepository.sanitizeFileName("hello/world"))
assertEquals("file_name", PlaintextExportRepository.sanitizeFileName("file:name"))
assertEquals("a_b_c_d", PlaintextExportRepository.sanitizeFileName("a\\b*c?d"))
assertEquals("test_file_", PlaintextExportRepository.sanitizeFileName("test<file>"))
assertEquals("pipe_here", PlaintextExportRepository.sanitizeFileName("pipe|here"))
assertEquals("quote_test", PlaintextExportRepository.sanitizeFileName("quote\"test"))
}
@Test
fun `sanitizeFileName trims whitespace`() {
assertEquals("hello", PlaintextExportRepository.sanitizeFileName(" hello "))
}
@Test
fun `sanitizeFileName truncates to 100 characters`() {
val longName = "a".repeat(200)
assertEquals(100, PlaintextExportRepository.sanitizeFileName(longName).length)
}
@Test
fun `sanitizeFileName preserves normal characters`() {
assertEquals("My Chat Group 2024", PlaintextExportRepository.sanitizeFileName("My Chat Group 2024"))
}
// ==================== getSenderName tests ====================
@Test
fun `getSenderName returns You for outgoing`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
mailbox = MessageTypes.BASE_SENT_TYPE
)
assertEquals("You", PlaintextExportRepository.getSenderName(context, message))
}
@Test
fun `getSenderName returns display name for incoming`() {
val message = FakeMessageRecords.buildMediaMmsMessageRecord(
conversationRecipient = mockRecipient,
individualRecipient = mockRecipient,
mailbox = MessageTypes.BASE_INBOX_TYPE
)
assertEquals("Alice", PlaintextExportRepository.getSenderName(context, message))
}
// ==================== Helpers ====================
private fun renderMessage(
message: org.thoughtcrime.securesms.database.model.MmsMessageRecord,
attachments: List<org.thoughtcrime.securesms.attachments.DatabaseAttachment> = emptyList(),
pendingAttachments: MutableList<PendingAttachment> = mutableListOf(),
poll: PollRecord? = null
): String {
val stringWriter = StringWriter()
val writer = BufferedWriter(stringWriter)
val extraData = PlaintextExportRepository.ExtraMessageData(
attachmentsById = if (attachments.isNotEmpty()) mapOf(message.id to attachments) else emptyMap(),
mentionsById = emptyMap(),
pollsById = if (poll != null) mapOf(message.id to poll) else emptyMap()
)
with(PlaintextExportRepository) {
writer.writeMessage(
context = context,
message = message,
extraData = extraData,
dateFormat = dateFormat,
attachmentDateFormat = attachmentDateFormat,
pendingAttachments = pendingAttachments
)
}
writer.flush()
return stringWriter.toString()
}
}

View File

@@ -19,7 +19,9 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.payments.Payment
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.MediaUtil
@@ -160,7 +162,9 @@ object FakeMessageRecords {
parentStoryId: ParentStoryId? = null,
giftBadge: GiftBadge? = null,
payment: Payment? = null,
call: CallTable.Call? = null
call: CallTable.Call? = null,
poll: PollRecord? = null,
deletedBy: RecipientId? = null
): MmsMessageRecord {
return MmsMessageRecord(
id,
@@ -198,14 +202,14 @@ object FakeMessageRecords {
giftBadge,
payment,
call,
null,
poll,
-1,
null,
null,
0,
false,
0,
null,
deletedBy,
null
)
}