From 4c96bb2056d230f0e43c368af996d51fad1acad7 Mon Sep 17 00:00:00 2001 From: Naveen Singh <36371707+naveensingh@users.noreply.github.com> Date: Thu, 25 Sep 2025 21:25:19 +0530 Subject: [PATCH] feat: allow saving multiple attachments (#528) * feat: allow saving multiple attachments Refs: https://github.com/FossifyOrg/Messages/issues/75 * docs: update changelog * docs: update changelog * fix: move work to the background thread * feat: allow saving attachment selections from different messages Bonus for https://github.com/FossifyOrg/Messages/issues/75 --- CHANGELOG.md | 3 + .../messages/activities/ThreadActivity.kt | 95 ++++++++++++------- .../messages/adapters/ThreadAdapter.kt | 27 ++++-- .../fossify/messages/extensions/Context.kt | 8 ++ .../org/fossify/messages/helpers/Constants.kt | 1 + .../org/fossify/messages/models/Attachment.kt | 4 +- 6 files changed, 91 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03286664..a319e604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Ability to save multiple attachments ([#75]) ## [1.3.0] - 2025-09-09 ### Added @@ -127,6 +129,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#13]: https://github.com/FossifyOrg/Messages/issues/13 [#70]: https://github.com/FossifyOrg/Messages/issues/70 +[#75]: https://github.com/FossifyOrg/Messages/issues/75 [#115]: https://github.com/FossifyOrg/Messages/issues/115 [#135]: https://github.com/FossifyOrg/Messages/issues/135 [#180]: https://github.com/FossifyOrg/Messages/issues/180 diff --git a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt index 1251f19b..a053c42e 100644 --- a/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt +++ b/app/src/main/kotlin/org/fossify/messages/activities/ThreadActivity.kt @@ -5,6 +5,8 @@ import android.app.Activity import android.app.AlarmManager import android.content.ActivityNotFoundException import android.content.Intent +import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION +import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION import android.content.res.ColorStateList import android.graphics.BitmapFactory import android.graphics.drawable.LayerDrawable @@ -12,6 +14,7 @@ import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Bundle import android.provider.ContactsContract +import android.provider.DocumentsContract import android.provider.MediaStore import android.provider.Telephony import android.provider.Telephony.Sms.MESSAGE_TYPE_QUEUED @@ -44,6 +47,7 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams +import androidx.documentfile.provider.DocumentFile import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.gson.Gson @@ -62,6 +66,7 @@ import org.fossify.commons.extensions.darkenColor import org.fossify.commons.extensions.formatDate import org.fossify.commons.extensions.getBottomNavigationBackgroundColor import org.fossify.commons.extensions.getContrastColor +import org.fossify.commons.extensions.getFilenameFromPath import org.fossify.commons.extensions.getFilenameFromUri import org.fossify.commons.extensions.getMyContactsCursor import org.fossify.commons.extensions.getMyFileUri @@ -113,6 +118,7 @@ import org.fossify.messages.dialogs.ScheduleMessageDialog import org.fossify.messages.extensions.clearExpiredScheduledMessages import org.fossify.messages.extensions.config import org.fossify.messages.extensions.conversationsDB +import org.fossify.messages.extensions.copyToUri import org.fossify.messages.extensions.createTemporaryThread import org.fossify.messages.extensions.deleteConversation import org.fossify.messages.extensions.deleteMessage @@ -158,6 +164,7 @@ import org.fossify.messages.helpers.MESSAGES_LIMIT import org.fossify.messages.helpers.PICK_CONTACT_INTENT import org.fossify.messages.helpers.PICK_DOCUMENT_INTENT import org.fossify.messages.helpers.PICK_PHOTO_INTENT +import org.fossify.messages.helpers.PICK_SAVE_DIR_INTENT import org.fossify.messages.helpers.PICK_SAVE_FILE_INTENT import org.fossify.messages.helpers.PICK_VIDEO_INTENT import org.fossify.messages.helpers.SEARCHED_MESSAGE_ID @@ -192,8 +199,6 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.joda.time.DateTime import java.io.File -import java.io.InputStream -import java.io.OutputStream class ThreadActivity : SimpleActivity() { private val MIN_DATE_TIME_DIFF_SECS = 300 @@ -215,7 +220,7 @@ class ThreadActivity : SimpleActivity() { private var privateContacts = ArrayList() private var messages = ArrayList() private val availableSIMCards = ArrayList() - private var lastAttachmentUri: String? = null + private var pendingAttachmentsToSave: List? = null private var capturedImageUri: Uri? = null private var loadingOlderMessages = false private var allMessagesFetched = false @@ -420,7 +425,8 @@ class ThreadActivity : SimpleActivity() { PICK_VIDEO_INTENT -> addAttachment(data) PICK_CONTACT_INTENT -> addContactAttachment(data) - PICK_SAVE_FILE_INTENT -> saveAttachment(resultData) + PICK_SAVE_FILE_INTENT -> saveAttachments(resultData) + PICK_SAVE_DIR_INTENT -> saveAttachments(resultData) } } } @@ -1485,29 +1491,35 @@ class ThreadActivity : SimpleActivity() { checkSendMessageAvailability() } - private fun saveAttachment(resultData: Intent) { - val takeFlags = - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + private fun saveAttachments(resultData: Intent) { applicationContext.contentResolver.takePersistableUriPermission( - resultData.data!!, - takeFlags + resultData.data!!, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION ) - var inputStream: InputStream? = null - var outputStream: OutputStream? = null - try { - inputStream = contentResolver.openInputStream(Uri.parse(lastAttachmentUri)) - outputStream = - contentResolver.openOutputStream(Uri.parse(resultData.dataString!!), "rwt") - inputStream!!.copyTo(outputStream!!) - outputStream.flush() - toast(org.fossify.commons.R.string.file_saved) - } catch (e: Exception) { - showErrorToast(e) - } finally { - inputStream?.close() - outputStream?.close() + val destinationUri = resultData.data ?: return + ensureBackgroundThread { + try { + if (DocumentsContract.isTreeUri(destinationUri)) { + val outputDir = DocumentFile.fromTreeUri(this, destinationUri) + ?: return@ensureBackgroundThread + pendingAttachmentsToSave?.forEach { attachment -> + val documentFile = outputDir.createFile( + attachment.mimetype, + attachment.filename.takeIf { it.isNotBlank() } + ?: attachment.uriString.getFilenameFromPath() + ) ?: return@forEach + copyToUri(src = attachment.getUri(), dst = documentFile.uri) + } + } else { + copyToUri(pendingAttachmentsToSave!!.first().getUri(), resultData.data!!) + } + + toast(org.fossify.commons.R.string.file_saved) + } catch (e: Exception) { + showErrorToast(e) + } finally { + pendingAttachmentsToSave = null + } } - lastAttachmentUri = null } private fun checkSendMessageAvailability() { @@ -1745,18 +1757,29 @@ class ThreadActivity : SimpleActivity() { return participants } - fun saveMMS(mimeType: String, path: String) { - hideKeyboard() - lastAttachmentUri = path - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - type = mimeType - addCategory(Intent.CATEGORY_OPENABLE) - putExtra(Intent.EXTRA_TITLE, path.split("/").last()) - launchActivityForResult( - intent = this, - requestCode = PICK_SAVE_FILE_INTENT, - error = org.fossify.commons.R.string.system_service_disabled - ) + fun saveMMS(attachments: List) { + pendingAttachmentsToSave = attachments + if (attachments.size == 1) { + val attachment = attachments.first() + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + type = attachment.mimetype + addCategory(Intent.CATEGORY_OPENABLE) + putExtra(Intent.EXTRA_TITLE, attachment.uriString.split("/").last()) + launchActivityForResult( + intent = this, + requestCode = PICK_SAVE_FILE_INTENT, + error = org.fossify.commons.R.string.system_service_disabled + ) + } + } else { + Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + addCategory(Intent.CATEGORY_DEFAULT) + launchActivityForResult( + intent = this, + requestCode = PICK_SAVE_DIR_INTENT, + error = org.fossify.commons.R.string.system_service_disabled + ) + } } } diff --git a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt index e1302894..7b9226a7 100644 --- a/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt +++ b/app/src/main/kotlin/org/fossify/messages/adapters/ThreadAdapter.kt @@ -4,8 +4,6 @@ import android.annotation.SuppressLint import android.content.Intent import android.graphics.Color import android.graphics.Typeface -import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.util.Size import android.util.TypedValue @@ -49,6 +47,7 @@ import org.fossify.messages.models.Attachment import org.fossify.messages.models.Message import org.fossify.messages.models.ThreadItem import org.fossify.messages.models.ThreadItem.* +import androidx.core.graphics.drawable.toDrawable class ThreadAdapter( activity: SimpleActivity, @@ -74,9 +73,13 @@ class ThreadAdapter( val isOneItemSelected = isOneItemSelected() val selectedItem = getSelectedItems().firstOrNull() as? Message val hasText = selectedItem?.body != null && selectedItem.body != "" + val showSaveAs = getSelectedItems().all { + it is Message && (it.attachment?.attachments?.size ?: 0) > 0 + } && getSelectedAttachments().isNotEmpty() + menu.apply { findItem(R.id.cab_copy_to_clipboard).isVisible = isOneItemSelected && hasText - findItem(R.id.cab_save_as).isVisible = isOneItemSelected && selectedItem?.attachment?.attachments?.size == 1 + findItem(R.id.cab_save_as).isVisible = showSaveAs findItem(R.id.cab_share).isVisible = isOneItemSelected && hasText findItem(R.id.cab_forward_message).isVisible = isOneItemSelected findItem(R.id.cab_select_text).isVisible = isOneItemSelected && hasText @@ -168,10 +171,16 @@ class ThreadAdapter( activity.copyToClipboard(firstItem.body) } + private fun getSelectedAttachments(): List { + val selectedMessages = getSelectedItems().filterIsInstance() + return selectedMessages.flatMap { it.attachment?.attachments.orEmpty() } + } + private fun saveAs() { - val firstItem = getSelectedItems().firstOrNull() as? Message ?: return - val attachment = firstItem.attachment?.attachments?.first() ?: return - (activity as ThreadActivity).saveMMS(attachment.mimetype, attachment.uriString) + val attachments = getSelectedAttachments() + if (attachments.isNotEmpty()) { + (activity as ThreadActivity).saveMMS(attachments) + } } private fun shareText() { @@ -342,7 +351,7 @@ class ThreadAdapter( if (!activity.isFinishing && !activity.isDestroyed) { val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName) - val placeholder = BitmapDrawable(activity.resources, contactLetterIcon) + val placeholder = contactLetterIcon.toDrawable(activity.resources) val options = RequestOptions() .diskCacheStrategy(DiskCacheStrategy.RESOURCE) @@ -406,7 +415,7 @@ class ThreadAdapter( val imageView = ItemAttachmentImageBinding.inflate(layoutInflater) threadMessageAttachmentsHolder.addView(imageView.root) - val placeholderDrawable = ColorDrawable(Color.TRANSPARENT) + val placeholderDrawable = Color.TRANSPARENT.toDrawable() val isTallImage = attachment.height > attachment.width val transformation = if (isTallImage) CenterCrop() else FitCenter() val options = RequestOptions() @@ -442,7 +451,7 @@ class ThreadAdapter( try { builder.into(imageView.attachmentImage) - } catch (ignore: Exception) { + } catch (_: Exception) { } imageView.attachmentImage.setOnClickListener { diff --git a/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt b/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt index dfd9572f..35ffae2e 100644 --- a/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt +++ b/app/src/main/kotlin/org/fossify/messages/extensions/Context.kt @@ -1317,3 +1317,11 @@ fun Context.getDefaultKeyboardHeight(): Int { fun Context.shouldUnarchive(): Boolean { return config.isArchiveAvailable && !config.keepConversationsArchived } + +fun Context.copyToUri(src: Uri, dst: Uri) { + contentResolver.openInputStream(src)?.use { input -> + contentResolver.openOutputStream(dst, "rwt")?.use { out -> + input.copyTo(out) + } + } +} diff --git a/app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt b/app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt index a0a01520..bb656c12 100644 --- a/app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt +++ b/app/src/main/kotlin/org/fossify/messages/helpers/Constants.kt @@ -92,6 +92,7 @@ const val CAPTURE_VIDEO_INTENT = 45 const val CAPTURE_AUDIO_INTENT = 46 const val PICK_DOCUMENT_INTENT = 47 const val PICK_CONTACT_INTENT = 48 +const val PICK_SAVE_DIR_INTENT = 50 const val BLOCKED_KEYWORDS_EXPORT_DELIMITER = "," const val BLOCKED_KEYWORDS_EXPORT_EXTENSION = ".txt" diff --git a/app/src/main/kotlin/org/fossify/messages/models/Attachment.kt b/app/src/main/kotlin/org/fossify/messages/models/Attachment.kt index 242184d8..4d99bd5e 100644 --- a/app/src/main/kotlin/org/fossify/messages/models/Attachment.kt +++ b/app/src/main/kotlin/org/fossify/messages/models/Attachment.kt @@ -1,6 +1,6 @@ package org.fossify.messages.models -import android.net.Uri +import androidx.core.net.toUri import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index @@ -17,5 +17,5 @@ data class Attachment( @ColumnInfo(name = "filename") var filename: String ) { - fun getUri() = Uri.parse(uriString) + fun getUri() = uriString.toUri() }