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:
parent
3fb86af731
commit
4c96bb2056
6 changed files with 91 additions and 47 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
toast(org.fossify.commons.R.string.file_saved)
|
attachment.mimetype,
|
||||||
} catch (e: Exception) {
|
attachment.filename.takeIf { it.isNotBlank() }
|
||||||
showErrorToast(e)
|
?: attachment.uriString.getFilenameFromPath()
|
||||||
} finally {
|
) ?: return@forEach
|
||||||
inputStream?.close()
|
copyToUri(src = attachment.getUri(), dst = documentFile.uri)
|
||||||
outputStream?.close()
|
}
|
||||||
|
} 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() {
|
private fun checkSendMessageAvailability() {
|
||||||
|
|
@ -1745,18 +1757,29 @@ 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) {
|
||||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
val attachment = attachments.first()
|
||||||
type = mimeType
|
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||||
addCategory(Intent.CATEGORY_OPENABLE)
|
type = attachment.mimetype
|
||||||
putExtra(Intent.EXTRA_TITLE, path.split("/").last())
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
launchActivityForResult(
|
putExtra(Intent.EXTRA_TITLE, attachment.uriString.split("/").last())
|
||||||
intent = this,
|
launchActivityForResult(
|
||||||
requestCode = PICK_SAVE_FILE_INTENT,
|
intent = this,
|
||||||
error = org.fossify.commons.R.string.system_service_disabled
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue