diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SignalProgressDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SignalProgressDialog.kt index 00ded55dd7..8b8bbe9438 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SignalProgressDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SignalProgressDialog.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt index 5818087ac2..6fae813899 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationOptionsMenu.kt @@ -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() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepository.kt new file mode 100644 index 0000000000..c468c8c034 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepository.kt @@ -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() + 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 { + val batch = ArrayList(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, 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 + ) { + 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 + ) { + 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, + prefix: String, + wrotePrefix: Boolean, + attachmentDateFormat: SimpleDateFormat, + pendingAttachments: MutableList + ) { + 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): 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 ExecutorService.submitTyped(callable: Callable): Future { + 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>, + val mentionsById: Map>, + val pollsById: Map + ) + + fun interface ProgressListener { + fun onProgress(messagesProcessed: Int, messageCount: Int, attachmentsProcessed: Int, attachmentCount: Int) + } + + fun interface CancellationSignal { + fun isCancelled(): Boolean + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index a979b62eab..db6a084fd6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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 + private lateinit var plaintextExportDirectoryLauncher: ActivityResultLauncher 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 1ed90f9d0b..f4068858ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -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>(emptyList()) val pinnedMessages: StateFlow> = internalPinnedMessages + private val _plaintextExportState = MutableStateFlow(PlaintextExportState.None) + val plaintextExportState: StateFlow = _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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 6b28944fd2..cf65295ed5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -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): Map> { if (mmsIds.isEmpty()) { return emptyMap() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 1e0796287d..d329bd1e2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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() } diff --git a/app/src/main/res/layout/signal_progress_dialog.xml b/app/src/main/res/layout/signal_progress_dialog.xml index 77c557d593..0b64b6ea69 100644 --- a/app/src/main/res/layout/signal_progress_dialog.xml +++ b/app/src/main/res/layout/signal_progress_dialog.xml @@ -5,13 +5,16 @@ + android:layout_height="wrap_content" + android:paddingStart="24dp" + android:paddingEnd="24dp" + android:paddingTop="24dp" + android:paddingBottom="24dp"> @@ -31,13 +35,11 @@ - \ No newline at end of file + app:layout_constraintTop_toBottomOf="@id/progress_dialog_title" /> + diff --git a/app/src/main/res/menu/conversation.xml b/app/src/main/res/menu/conversation.xml index 51510c1c34..ad1d0a827d 100644 --- a/app/src/main/res/menu/conversation.xml +++ b/app/src/main/res/menu/conversation.xml @@ -25,6 +25,10 @@ android:id="@+id/menu_create_bubble" android:title="@string/conversation__menu_create_bubble" /> + + All media Chat settings Add to home screen + Export Create bubble Format text @@ -4660,6 +4661,13 @@ Add to contacts + + Exporting chat… + Chat exported successfully + Export failed + Export cancelled + Preparing export… + diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepositoryTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepositoryTest.kt new file mode 100644 index 0000000000..0b6ff5817e --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/plaintext/PlaintextExportRepositoryTest.kt @@ -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(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() + 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() + 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() + 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() + 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() + 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() + 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")) + 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 = emptyList(), + pendingAttachments: MutableList = 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() + } +} diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index 5513f7c9ee..75d300f286 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -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 ) }