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).
## [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

View file

@ -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<SimpleContact>()
private var messages = ArrayList<Message>()
private val availableSIMCards = ArrayList<SIMCard>()
private var lastAttachmentUri: String? = null
private var pendingAttachmentsToSave: List<Attachment>? = 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<Attachment>) {
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
)
}
}
}

View file

@ -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<Attachment> {
val selectedMessages = getSelectedItems().filterIsInstance<Message>()
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 {

View file

@ -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)
}
}
}

View file

@ -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"

View file

@ -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()
}