mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-02 08:23:00 +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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user