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
This commit is contained in:
Naveen Singh 2025-09-25 21:25:19 +05:30 committed by GitHub
parent 3fb86af731
commit 4c96bb2056
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 91 additions and 47 deletions

View file

@ -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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- Ability to save multiple attachments ([#75])
## [1.3.0] - 2025-09-09 ## [1.3.0] - 2025-09-09
### Added ### 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 [#13]: https://github.com/FossifyOrg/Messages/issues/13
[#70]: https://github.com/FossifyOrg/Messages/issues/70 [#70]: https://github.com/FossifyOrg/Messages/issues/70
[#75]: https://github.com/FossifyOrg/Messages/issues/75
[#115]: https://github.com/FossifyOrg/Messages/issues/115 [#115]: https://github.com/FossifyOrg/Messages/issues/115
[#135]: https://github.com/FossifyOrg/Messages/issues/135 [#135]: https://github.com/FossifyOrg/Messages/issues/135
[#180]: https://github.com/FossifyOrg/Messages/issues/180 [#180]: https://github.com/FossifyOrg/Messages/issues/180

View file

@ -5,6 +5,8 @@ import android.app.Activity
import android.app.AlarmManager import android.app.AlarmManager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent 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.content.res.ColorStateList
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
@ -12,6 +14,7 @@ import android.media.MediaMetadataRetriever
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.ContactsContract import android.provider.ContactsContract
import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.Telephony import android.provider.Telephony
import android.provider.Telephony.Sms.MESSAGE_TYPE_QUEUED 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.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updateLayoutParams import androidx.core.view.updateLayoutParams
import androidx.documentfile.provider.DocumentFile
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.gson.Gson 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.formatDate
import org.fossify.commons.extensions.getBottomNavigationBackgroundColor import org.fossify.commons.extensions.getBottomNavigationBackgroundColor
import org.fossify.commons.extensions.getContrastColor import org.fossify.commons.extensions.getContrastColor
import org.fossify.commons.extensions.getFilenameFromPath
import org.fossify.commons.extensions.getFilenameFromUri import org.fossify.commons.extensions.getFilenameFromUri
import org.fossify.commons.extensions.getMyContactsCursor import org.fossify.commons.extensions.getMyContactsCursor
import org.fossify.commons.extensions.getMyFileUri 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.clearExpiredScheduledMessages
import org.fossify.messages.extensions.config import org.fossify.messages.extensions.config
import org.fossify.messages.extensions.conversationsDB import org.fossify.messages.extensions.conversationsDB
import org.fossify.messages.extensions.copyToUri
import org.fossify.messages.extensions.createTemporaryThread import org.fossify.messages.extensions.createTemporaryThread
import org.fossify.messages.extensions.deleteConversation import org.fossify.messages.extensions.deleteConversation
import org.fossify.messages.extensions.deleteMessage 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_CONTACT_INTENT
import org.fossify.messages.helpers.PICK_DOCUMENT_INTENT import org.fossify.messages.helpers.PICK_DOCUMENT_INTENT
import org.fossify.messages.helpers.PICK_PHOTO_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_SAVE_FILE_INTENT
import org.fossify.messages.helpers.PICK_VIDEO_INTENT import org.fossify.messages.helpers.PICK_VIDEO_INTENT
import org.fossify.messages.helpers.SEARCHED_MESSAGE_ID import org.fossify.messages.helpers.SEARCHED_MESSAGE_ID
@ -192,8 +199,6 @@ import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import org.joda.time.DateTime import org.joda.time.DateTime
import java.io.File import java.io.File
import java.io.InputStream
import java.io.OutputStream
class ThreadActivity : SimpleActivity() { class ThreadActivity : SimpleActivity() {
private val MIN_DATE_TIME_DIFF_SECS = 300 private val MIN_DATE_TIME_DIFF_SECS = 300
@ -215,7 +220,7 @@ class ThreadActivity : SimpleActivity() {
private var privateContacts = ArrayList<SimpleContact>() private var privateContacts = ArrayList<SimpleContact>()
private var messages = ArrayList<Message>() private var messages = ArrayList<Message>()
private val availableSIMCards = ArrayList<SIMCard>() private val availableSIMCards = ArrayList<SIMCard>()
private var lastAttachmentUri: String? = null private var pendingAttachmentsToSave: List<Attachment>? = null
private var capturedImageUri: Uri? = null private var capturedImageUri: Uri? = null
private var loadingOlderMessages = false private var loadingOlderMessages = false
private var allMessagesFetched = false private var allMessagesFetched = false
@ -420,7 +425,8 @@ class ThreadActivity : SimpleActivity() {
PICK_VIDEO_INTENT -> addAttachment(data) PICK_VIDEO_INTENT -> addAttachment(data)
PICK_CONTACT_INTENT -> addContactAttachment(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() checkSendMessageAvailability()
} }
private fun saveAttachment(resultData: Intent) { private fun saveAttachments(resultData: Intent) {
val takeFlags =
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
applicationContext.contentResolver.takePersistableUriPermission( applicationContext.contentResolver.takePersistableUriPermission(
resultData.data!!, resultData.data!!, FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION
takeFlags
) )
var inputStream: InputStream? = null val destinationUri = resultData.data ?: return
var outputStream: OutputStream? = null ensureBackgroundThread {
try { try {
inputStream = contentResolver.openInputStream(Uri.parse(lastAttachmentUri)) if (DocumentsContract.isTreeUri(destinationUri)) {
outputStream = val outputDir = DocumentFile.fromTreeUri(this, destinationUri)
contentResolver.openOutputStream(Uri.parse(resultData.dataString!!), "rwt") ?: return@ensureBackgroundThread
inputStream!!.copyTo(outputStream!!) pendingAttachmentsToSave?.forEach { attachment ->
outputStream.flush() 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) toast(org.fossify.commons.R.string.file_saved)
} catch (e: Exception) { } catch (e: Exception) {
showErrorToast(e) showErrorToast(e)
} finally { } finally {
inputStream?.close() pendingAttachmentsToSave = null
outputStream?.close() }
} }
lastAttachmentUri = null
} }
private fun checkSendMessageAvailability() { private fun checkSendMessageAvailability() {
@ -1745,19 +1757,30 @@ class ThreadActivity : SimpleActivity() {
return participants return participants
} }
fun saveMMS(mimeType: String, path: String) { fun saveMMS(attachments: List<Attachment>) {
hideKeyboard() pendingAttachmentsToSave = attachments
lastAttachmentUri = path if (attachments.size == 1) {
val attachment = attachments.first()
Intent(Intent.ACTION_CREATE_DOCUMENT).apply { Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
type = mimeType type = attachment.mimetype
addCategory(Intent.CATEGORY_OPENABLE) addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, path.split("/").last()) putExtra(Intent.EXTRA_TITLE, attachment.uriString.split("/").last())
launchActivityForResult( launchActivityForResult(
intent = this, intent = this,
requestCode = PICK_SAVE_FILE_INTENT, requestCode = PICK_SAVE_FILE_INTENT,
error = org.fossify.commons.R.string.system_service_disabled 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
)
}
}
} }
@Subscribe(threadMode = ThreadMode.ASYNC) @Subscribe(threadMode = ThreadMode.ASYNC)

View file

@ -4,8 +4,6 @@ import android.annotation.SuppressLint
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.Typeface import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.Size import android.util.Size
import android.util.TypedValue 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.Message
import org.fossify.messages.models.ThreadItem import org.fossify.messages.models.ThreadItem
import org.fossify.messages.models.ThreadItem.* import org.fossify.messages.models.ThreadItem.*
import androidx.core.graphics.drawable.toDrawable
class ThreadAdapter( class ThreadAdapter(
activity: SimpleActivity, activity: SimpleActivity,
@ -74,9 +73,13 @@ class ThreadAdapter(
val isOneItemSelected = isOneItemSelected() val isOneItemSelected = isOneItemSelected()
val selectedItem = getSelectedItems().firstOrNull() as? Message val selectedItem = getSelectedItems().firstOrNull() as? Message
val hasText = selectedItem?.body != null && selectedItem.body != "" val hasText = selectedItem?.body != null && selectedItem.body != ""
val showSaveAs = getSelectedItems().all {
it is Message && (it.attachment?.attachments?.size ?: 0) > 0
} && getSelectedAttachments().isNotEmpty()
menu.apply { menu.apply {
findItem(R.id.cab_copy_to_clipboard).isVisible = isOneItemSelected && hasText 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_share).isVisible = isOneItemSelected && hasText
findItem(R.id.cab_forward_message).isVisible = isOneItemSelected findItem(R.id.cab_forward_message).isVisible = isOneItemSelected
findItem(R.id.cab_select_text).isVisible = isOneItemSelected && hasText findItem(R.id.cab_select_text).isVisible = isOneItemSelected && hasText
@ -168,10 +171,16 @@ class ThreadAdapter(
activity.copyToClipboard(firstItem.body) activity.copyToClipboard(firstItem.body)
} }
private fun getSelectedAttachments(): List<Attachment> {
val selectedMessages = getSelectedItems().filterIsInstance<Message>()
return selectedMessages.flatMap { it.attachment?.attachments.orEmpty() }
}
private fun saveAs() { private fun saveAs() {
val firstItem = getSelectedItems().firstOrNull() as? Message ?: return val attachments = getSelectedAttachments()
val attachment = firstItem.attachment?.attachments?.first() ?: return if (attachments.isNotEmpty()) {
(activity as ThreadActivity).saveMMS(attachment.mimetype, attachment.uriString) (activity as ThreadActivity).saveMMS(attachments)
}
} }
private fun shareText() { private fun shareText() {
@ -342,7 +351,7 @@ class ThreadAdapter(
if (!activity.isFinishing && !activity.isDestroyed) { if (!activity.isFinishing && !activity.isDestroyed) {
val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName) val contactLetterIcon = SimpleContactsHelper(activity).getContactLetterIcon(message.senderName)
val placeholder = BitmapDrawable(activity.resources, contactLetterIcon) val placeholder = contactLetterIcon.toDrawable(activity.resources)
val options = RequestOptions() val options = RequestOptions()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE) .diskCacheStrategy(DiskCacheStrategy.RESOURCE)
@ -406,7 +415,7 @@ class ThreadAdapter(
val imageView = ItemAttachmentImageBinding.inflate(layoutInflater) val imageView = ItemAttachmentImageBinding.inflate(layoutInflater)
threadMessageAttachmentsHolder.addView(imageView.root) threadMessageAttachmentsHolder.addView(imageView.root)
val placeholderDrawable = ColorDrawable(Color.TRANSPARENT) val placeholderDrawable = Color.TRANSPARENT.toDrawable()
val isTallImage = attachment.height > attachment.width val isTallImage = attachment.height > attachment.width
val transformation = if (isTallImage) CenterCrop() else FitCenter() val transformation = if (isTallImage) CenterCrop() else FitCenter()
val options = RequestOptions() val options = RequestOptions()
@ -442,7 +451,7 @@ class ThreadAdapter(
try { try {
builder.into(imageView.attachmentImage) builder.into(imageView.attachmentImage)
} catch (ignore: Exception) { } catch (_: Exception) {
} }
imageView.attachmentImage.setOnClickListener { imageView.attachmentImage.setOnClickListener {

View file

@ -1317,3 +1317,11 @@ fun Context.getDefaultKeyboardHeight(): Int {
fun Context.shouldUnarchive(): Boolean { fun Context.shouldUnarchive(): Boolean {
return config.isArchiveAvailable && !config.keepConversationsArchived 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)
}
}
}

View file

@ -92,6 +92,7 @@ const val CAPTURE_VIDEO_INTENT = 45
const val CAPTURE_AUDIO_INTENT = 46 const val CAPTURE_AUDIO_INTENT = 46
const val PICK_DOCUMENT_INTENT = 47 const val PICK_DOCUMENT_INTENT = 47
const val PICK_CONTACT_INTENT = 48 const val PICK_CONTACT_INTENT = 48
const val PICK_SAVE_DIR_INTENT = 50
const val BLOCKED_KEYWORDS_EXPORT_DELIMITER = "," const val BLOCKED_KEYWORDS_EXPORT_DELIMITER = ","
const val BLOCKED_KEYWORDS_EXPORT_EXTENSION = ".txt" const val BLOCKED_KEYWORDS_EXPORT_EXTENSION = ".txt"

View file

@ -1,6 +1,6 @@
package org.fossify.messages.models package org.fossify.messages.models
import android.net.Uri import androidx.core.net.toUri
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
@ -17,5 +17,5 @@ data class Attachment(
@ColumnInfo(name = "filename") var filename: String @ColumnInfo(name = "filename") var filename: String
) { ) {
fun getUri() = Uri.parse(uriString) fun getUri() = uriString.toUri()
} }