mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-17 07:23:21 +01:00
Switch the labs plaintext export to share a single zip.
This commit is contained in:
committed by
jeffrey-signal
parent
086883e565
commit
78940ffc17
@@ -6,13 +6,10 @@
|
||||
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
|
||||
@@ -25,14 +22,19 @@ 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.BufferedOutputStream
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.OutputStreamWriter
|
||||
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
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
/**
|
||||
* Exports a conversation thread as user-friendly plaintext with attachments.
|
||||
@@ -45,7 +47,7 @@ object PlaintextExportRepository {
|
||||
fun export(
|
||||
context: Context,
|
||||
threadId: Long,
|
||||
directoryUri: Uri,
|
||||
outputFile: File,
|
||||
chatName: String,
|
||||
includeMedia: Boolean,
|
||||
progressListener: ProgressListener,
|
||||
@@ -55,49 +57,18 @@ object PlaintextExportRepository {
|
||||
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 = if (includeMedia) {
|
||||
chatDir.createDirectory("media") ?: run {
|
||||
Log.w(TAG, "Could not create media directory")
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
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 ->
|
||||
ZipOutputStream(BufferedOutputStream(outputFile.outputStream())).use { zipOut ->
|
||||
zipOut.putNextEntry(ZipEntry("$sanitizedName/chat.txt"))
|
||||
val writer = BufferedWriter(OutputStreamWriter(zipOut, Charsets.UTF_8))
|
||||
|
||||
writer.write("Chat export: $chatName")
|
||||
writer.newLine()
|
||||
writer.write("Exported on: ${dateFormat.format(Date())}")
|
||||
@@ -108,7 +79,6 @@ object PlaintextExportRepository {
|
||||
|
||||
val extraDataTimer = ParallelEventTimer()
|
||||
|
||||
// Messages
|
||||
MessageTable.mmsReaderFor(SignalDatabase.messages.getConversation(threadId, dateReceiveOrderBy = "ASC")).use { reader ->
|
||||
while (true) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
@@ -137,42 +107,36 @@ object PlaintextExportRepository {
|
||||
}
|
||||
|
||||
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.
|
||||
if (includeMedia && mediaDir != null) {
|
||||
val totalAttachments = pendingAttachments.size
|
||||
var attachmentsProcessed = 0
|
||||
for (pending in pendingAttachments) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
writer.flush()
|
||||
zipOut.closeEntry()
|
||||
|
||||
if (includeMedia) {
|
||||
val totalAttachments = pendingAttachments.size
|
||||
var attachmentsProcessed = 0
|
||||
for (pending in pendingAttachments) {
|
||||
if (cancellationSignal.isCancelled()) return false
|
||||
|
||||
try {
|
||||
zipOut.putNextEntry(ZipEntry("$sanitizedName/media/${pending.exportedName}"))
|
||||
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
|
||||
input.copyTo(zipOut)
|
||||
}
|
||||
zipOut.closeEntry()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e)
|
||||
}
|
||||
|
||||
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
|
||||
eventTimer.emit("media")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Error writing export zip", e)
|
||||
outputFile.delete()
|
||||
return false
|
||||
}
|
||||
|
||||
Log.d(TAG, "[PlaintextExport] ${eventTimer.stop().summary}")
|
||||
@@ -370,7 +334,6 @@ object PlaintextExportRepository {
|
||||
if (includeMedia) {
|
||||
val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat)
|
||||
pendingAttachments.add(PendingAttachment(attachment, exportedName))
|
||||
|
||||
val caption = attachment.caption
|
||||
if (caption != null) {
|
||||
this.write("[$label: media/$exportedName] $caption")
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.app.ActivityOptions
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
@@ -21,6 +22,7 @@ import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -106,7 +108,6 @@ 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
|
||||
@@ -332,6 +333,7 @@ import org.thoughtcrime.securesms.registration.ui.RegistrationActivity
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceUtil
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
|
||||
import org.thoughtcrime.securesms.sharing.v2.ShareActivity
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
import org.thoughtcrime.securesms.stickers.StickerEventListener
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
@@ -350,6 +352,7 @@ import org.thoughtcrime.securesms.util.DeleteDialog
|
||||
import org.thoughtcrime.securesms.util.Dialogs
|
||||
import org.thoughtcrime.securesms.util.DoubleClickDebouncer
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil
|
||||
import org.thoughtcrime.securesms.util.FileProviderUtil
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
|
||||
@@ -562,8 +565,6 @@ 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 var exportWithMedia = false
|
||||
private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
|
||||
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
|
||||
private lateinit var adapter: ConversationAdapterV2
|
||||
@@ -1633,13 +1634,6 @@ 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, exportWithMedia)
|
||||
}
|
||||
}
|
||||
conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks())
|
||||
}
|
||||
|
||||
@@ -1684,7 +1678,19 @@ class ConversationFragment :
|
||||
is ConversationViewModel.PlaintextExportState.Complete -> {
|
||||
progressDialog?.dismiss()
|
||||
progressDialog = null
|
||||
toast(R.string.conversation_export__export_complete, toastDuration = Toast.LENGTH_LONG)
|
||||
|
||||
val uri = FileProviderUtil.getUriFor(requireContext(), state.zipFile)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "application/zip"
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
val chooserIntent = Intent.createChooser(shareIntent, getString(R.string.conversation_export__export_complete))
|
||||
if (Build.VERSION.SDK_INT < 34) {
|
||||
chooserIntent.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(requireContext(), ShareActivity::class.java)))
|
||||
}
|
||||
startActivity(chooserIntent)
|
||||
|
||||
viewModel.clearPlaintextExportState()
|
||||
}
|
||||
|
||||
@@ -4330,12 +4336,10 @@ class ConversationFragment :
|
||||
.setTitle(R.string.ChatExportDialogs__export_chat_history_title)
|
||||
.setMessage(R.string.ChatExportDialogs__export_confirm_body)
|
||||
.setPositiveButton(R.string.ChatExportDialogs__export_with_media) { _, _ ->
|
||||
exportWithMedia = true
|
||||
plaintextExportDirectoryLauncher.launch(null)
|
||||
viewModel.startPlaintextExport(requireContext().applicationContext, includeMedia = true)
|
||||
}
|
||||
.setNeutralButton(R.string.ChatExportDialogs__export_without_media) { _, _ ->
|
||||
exportWithMedia = false
|
||||
plaintextExportDirectoryLauncher.launch(null)
|
||||
viewModel.startPlaintextExport(requireContext().applicationContext, includeMedia = false)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
@@ -101,6 +101,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.hasGiftBadge
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration
|
||||
@@ -761,10 +762,17 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun startPlaintextExport(context: Context, directoryUri: Uri, withMedia: Boolean) {
|
||||
fun startPlaintextExport(context: Context, includeMedia: Boolean) {
|
||||
val recipient = recipientSnapshot ?: return
|
||||
val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context)
|
||||
|
||||
val exportDir = File(context.externalCacheDir, "chat_exports")
|
||||
exportDir.mkdirs()
|
||||
exportDir.listFiles()?.forEach { it.delete() }
|
||||
|
||||
val sanitizedName = PlaintextExportRepository.sanitizeFileName(chatName)
|
||||
val outputFile = File(exportDir, "$sanitizedName.zip")
|
||||
|
||||
plaintextExportCancelled.set(false)
|
||||
_plaintextExportState.value = PlaintextExportState.Preparing
|
||||
|
||||
@@ -772,11 +780,11 @@ class ConversationViewModel(
|
||||
val success = PlaintextExportRepository.export(
|
||||
context = context,
|
||||
threadId = threadId,
|
||||
directoryUri = directoryUri,
|
||||
outputFile = outputFile,
|
||||
chatName = chatName,
|
||||
includeMedia = withMedia,
|
||||
includeMedia = includeMedia,
|
||||
progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount ->
|
||||
val percent = if (withMedia) {
|
||||
val percent = if (includeMedia) {
|
||||
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
|
||||
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
|
||||
messagePercent + attachmentPercent
|
||||
@@ -784,7 +792,7 @@ class ConversationViewModel(
|
||||
if (messageCount > 0) (messagesProcessed * 100) / messageCount else 100
|
||||
}
|
||||
|
||||
val status = if (withMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) {
|
||||
val status = if (includeMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) {
|
||||
"Exporting media ($attachmentsProcessed/$attachmentCount)..."
|
||||
} else {
|
||||
"Exporting messages ($messagesProcessed/$messageCount)..."
|
||||
@@ -796,8 +804,11 @@ class ConversationViewModel(
|
||||
)
|
||||
|
||||
_plaintextExportState.value = when {
|
||||
plaintextExportCancelled.get() -> PlaintextExportState.Cancelled
|
||||
success -> PlaintextExportState.Complete
|
||||
plaintextExportCancelled.get() -> {
|
||||
outputFile.delete()
|
||||
PlaintextExportState.Cancelled
|
||||
}
|
||||
success -> PlaintextExportState.Complete(outputFile)
|
||||
else -> PlaintextExportState.Failed
|
||||
}
|
||||
}
|
||||
@@ -821,7 +832,7 @@ class ConversationViewModel(
|
||||
data object None : PlaintextExportState
|
||||
data object Preparing : PlaintextExportState
|
||||
data class InProgress(val percent: Int, val status: String) : PlaintextExportState
|
||||
data object Complete : PlaintextExportState
|
||||
data class Complete(val zipFile: File) : PlaintextExportState
|
||||
data object Failed : PlaintextExportState
|
||||
data object Cancelled : PlaintextExportState
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user