mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-26 20:55:10 +00:00
Consolidate attachment saving logic into unified AttachmentSaver class.
Introduces `AttachmentSaver` to centralize all of the steps needed to save message attachments to the device storage. It handles the entire workflow including: - Showing the save to storage warning/confirmation dialog. - Requesting `WRITE_EXTERNAL_STORAGE` permission. - Showing/dismissing media save progress. Goals of this new class: - Make it easy to save media attachments anywhere with just a few lines of code (and easier to replace the deprecated `SaveAttachmentTask`). - Ensure all of the necessary steps are consistently performed at each usage site (which wasn't the case before). - Make it easier to unit test the save attachment logic.
This commit is contained in:
committed by
Cody Henthorne
parent
86afafac31
commit
b9dc5cbe4f
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.widget.CheckBox
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.util.AlertDialogResult
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentUtil
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentUtil.SaveAttachment
|
||||
import org.thoughtcrime.securesms.util.StorageUtil
|
||||
import org.thoughtcrime.securesms.util.awaitResult
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
/**
|
||||
* Executes all of the steps needed to save message attachments to the device storage, including:
|
||||
* - Showing the save to storage warning/confirmation dialog.
|
||||
* - Requesting WRITE_EXTERNAL_STORAGE permission.
|
||||
* - Showing/dismissing media save progress.
|
||||
*/
|
||||
class AttachmentSaver(private val host: Host) {
|
||||
|
||||
constructor(fragment: Fragment) : this(FragmentHost(fragment))
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(AttachmentSaver::class)
|
||||
private const val PROGRESS_DIALOG_TAG = "AttachmentSaver_progress_dialog"
|
||||
}
|
||||
|
||||
suspend fun saveAttachments(record: MmsMessageRecord) {
|
||||
val attachments = record.slideDeck.slides
|
||||
.filter { it.uri != null && (it.hasImage() || it.hasVideo() || it.hasAudio() || it.hasDocument()) }
|
||||
.map { SaveAttachment(it.uri!!, it.contentType, record.dateSent, it.fileName.orNull()) }
|
||||
.toSet()
|
||||
saveAttachments(attachments)
|
||||
}
|
||||
|
||||
suspend fun saveAttachments(attachments: Set<SaveAttachment>) {
|
||||
if (checkIsSaveWarningAccepted(attachmentCount = attachments.size) == SaveToStorageWarningResult.ACCEPTED) {
|
||||
if (checkCanWriteToMediaStore() == RequestPermissionResult.GRANTED) {
|
||||
Log.d(TAG, "Saving ${attachments.size} attachments to device storage.")
|
||||
saveToStorage(attachments)
|
||||
} else {
|
||||
Log.d(TAG, "Cancel saving ${attachments.size} attachments: media store permission denied.")
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Cancel saving ${attachments.size} attachments: save to storage warning denied.")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkIsSaveWarningAccepted(attachmentCount: Int): SaveToStorageWarningResult {
|
||||
if (SignalStore.uiHints.hasDismissedSaveStorageWarning()) {
|
||||
return SaveToStorageWarningResult.ACCEPTED
|
||||
}
|
||||
return host.showSaveToStorageWarning(attachmentCount)
|
||||
}
|
||||
|
||||
private suspend fun checkCanWriteToMediaStore(): RequestPermissionResult {
|
||||
if (StorageUtil.canWriteToMediaStore()) {
|
||||
return RequestPermissionResult.GRANTED
|
||||
}
|
||||
return host.requestWriteExternalStoragePermission()
|
||||
}
|
||||
|
||||
private suspend fun saveToStorage(attachments: Set<SaveAttachment>): SaveAttachmentUtil.SaveAttachmentsResult {
|
||||
host.showSaveProgress(attachmentCount = attachments.size)
|
||||
return try {
|
||||
val result = SaveAttachmentUtil.saveAttachments(attachments)
|
||||
withContext(SignalDispatchers.Main) {
|
||||
host.showToast { context -> result.getMessage(context) }
|
||||
}
|
||||
result
|
||||
} finally {
|
||||
withContext(SignalDispatchers.Main) {
|
||||
host.dismissSaveProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Host {
|
||||
suspend fun showSaveToStorageWarning(attachmentCount: Int): SaveToStorageWarningResult
|
||||
suspend fun requestWriteExternalStoragePermission(): RequestPermissionResult
|
||||
fun showToast(getMessage: (Context) -> CharSequence)
|
||||
fun showSaveProgress(attachmentCount: Int)
|
||||
fun dismissSaveProgress()
|
||||
}
|
||||
|
||||
private data class FragmentHost(private val fragment: Fragment) : Host {
|
||||
|
||||
override fun showToast(getMessage: (Context) -> CharSequence) {
|
||||
Toast.makeText(fragment.requireContext(), getMessage(fragment.requireContext()), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override suspend fun showSaveToStorageWarning(attachmentCount: Int): SaveToStorageWarningResult = withContext(SignalDispatchers.Main) {
|
||||
val dialog = MaterialAlertDialogBuilder(fragment.requireContext())
|
||||
.setView(R.layout.dialog_save_attachment)
|
||||
.setTitle(R.string.ConversationFragment__save_to_phone)
|
||||
.setCancelable(true)
|
||||
.setMessage(fragment.resources.getQuantityString(R.plurals.ConversationFragment__this_media_will_be_saved, attachmentCount, attachmentCount))
|
||||
.create()
|
||||
|
||||
val result = dialog.awaitResult(
|
||||
positiveButtonTextId = R.string.save,
|
||||
negativeButtonTextId = android.R.string.cancel
|
||||
)
|
||||
|
||||
if (result == AlertDialogResult.POSITIVE) {
|
||||
val dontShowAgainCheckbox = dialog.findViewById<CheckBox>(R.id.checkbox)!!
|
||||
if (dontShowAgainCheckbox.isChecked) {
|
||||
SignalStore.uiHints.markDismissedSaveStorageWarning()
|
||||
}
|
||||
return@withContext SaveToStorageWarningResult.ACCEPTED
|
||||
}
|
||||
return@withContext SaveToStorageWarningResult.DENIED
|
||||
}
|
||||
|
||||
override suspend fun requestWriteExternalStoragePermission(): RequestPermissionResult = withContext(SignalDispatchers.Main) {
|
||||
suspendCoroutine { continuation ->
|
||||
Permissions.with(fragment)
|
||||
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.withPermanentDenialDialog(fragment.getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
|
||||
.onAnyDenied {
|
||||
Log.d(TAG, "WRITE_EXTERNAL_STORAGE permission request denied.")
|
||||
continuation.resume(RequestPermissionResult.DENIED)
|
||||
}
|
||||
.onAllGranted {
|
||||
Log.d(TAG, "WRITE_EXTERNAL_STORAGE permission request granted.")
|
||||
continuation.resume(RequestPermissionResult.GRANTED)
|
||||
}
|
||||
.execute()
|
||||
}
|
||||
}
|
||||
|
||||
override fun showSaveProgress(attachmentCount: Int) {
|
||||
val progressMessage = fragment.resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, attachmentCount, attachmentCount)
|
||||
|
||||
val dialog = ProgressCardDialogFragment.create().apply {
|
||||
arguments = ProgressCardDialogFragmentArgs.Builder(progressMessage).build().toBundle()
|
||||
}
|
||||
|
||||
dialog.show(fragment.parentFragmentManager, PROGRESS_DIALOG_TAG)
|
||||
}
|
||||
|
||||
override fun dismissSaveProgress() {
|
||||
val dialog = fragment.parentFragmentManager.findFragmentByTag(PROGRESS_DIALOG_TAG)
|
||||
(dialog as ProgressCardDialogFragment).dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
enum class SaveToStorageWarningResult {
|
||||
ACCEPTED,
|
||||
DENIED
|
||||
}
|
||||
|
||||
enum class RequestPermissionResult {
|
||||
GRANTED,
|
||||
DENIED
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,6 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.rxSingle
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.greenrobot.eventbus.Subscribe
|
||||
import org.greenrobot.eventbus.ThreadMode
|
||||
@@ -111,6 +110,7 @@ import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.MuteDialog
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentSaver
|
||||
import org.thoughtcrime.securesms.audio.AudioRecorder
|
||||
import org.thoughtcrime.securesms.badges.gifts.OpenableGift
|
||||
import org.thoughtcrime.securesms.badges.gifts.OpenableGiftItemDecoration
|
||||
@@ -126,8 +126,6 @@ import org.thoughtcrime.securesms.components.HidingLinearLayout
|
||||
import org.thoughtcrime.securesms.components.InputAwareConstraintLayout
|
||||
import org.thoughtcrime.securesms.components.InputPanel
|
||||
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragmentArgs
|
||||
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
|
||||
import org.thoughtcrime.securesms.components.SendButton
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
@@ -322,9 +320,7 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil.getEditMessageThre
|
||||
import org.thoughtcrime.securesms.util.MessageConstraintsUtil.isValidEditMessageSend
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentUtil
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import org.thoughtcrime.securesms.util.StorageUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.WindowUtil
|
||||
@@ -2421,38 +2417,11 @@ class ConversationFragment :
|
||||
error("Cannot save a view-once message")
|
||||
}
|
||||
|
||||
val attachments = SaveAttachmentUtil.getAttachmentsForRecord(record)
|
||||
|
||||
SaveAttachmentUtil.showWarningDialogIfNecessary(requireContext(), attachments.size) {
|
||||
if (StorageUtil.canWriteToMediaStore()) {
|
||||
performAttachmentSave(attachments)
|
||||
} else {
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
|
||||
.onAnyDenied { toast(R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, toastDuration = Toast.LENGTH_LONG) }
|
||||
.onAllGranted { performAttachmentSave(attachments) }
|
||||
.execute()
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
AttachmentSaver(this@ConversationFragment).saveAttachments(record)
|
||||
}
|
||||
}
|
||||
|
||||
private fun performAttachmentSave(attachments: Set<SaveAttachmentUtil.SaveAttachment>) {
|
||||
val progressDialog = ProgressCardDialogFragment.create()
|
||||
progressDialog.arguments = ProgressCardDialogFragmentArgs.Builder(
|
||||
resources.getQuantityString(R.plurals.ConversationFragment_saving_n_attachments_to_sd_card, attachments.size, attachments.size)
|
||||
).build().toBundle()
|
||||
|
||||
rxSingle { SaveAttachmentUtil.saveAttachments(attachments) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSubscribe { progressDialog.show(parentFragmentManager, null) }
|
||||
.doOnTerminate { progressDialog.dismissAllowingStateLoss() }
|
||||
.subscribeBy { result -> Toast.makeText(context, result.getMessage(requireContext()), Toast.LENGTH_LONG).show() }
|
||||
.addTo(disposables)
|
||||
}
|
||||
|
||||
private fun handleCopyMessage(messageParts: Set<MultiselectPart>) {
|
||||
viewModel.copyToClipboard(requireContext(), messageParts).subscribe().addTo(disposables)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* Shows a dialog and suspends until user interaction, returning the [AlertDialogResult].
|
||||
*
|
||||
* Note: this method will overwrite any existing dialog button click listeners or cancellation listener.
|
||||
*/
|
||||
suspend fun AlertDialog.awaitResult(
|
||||
positiveButtonTextId: Int? = null,
|
||||
negativeButtonTextId: Int? = null,
|
||||
neutralButtonTextId: Int? = null
|
||||
) = awaitResult(
|
||||
positiveButtonText = positiveButtonTextId?.let(context::getString),
|
||||
negativeButtonText = negativeButtonTextId?.let(context::getString),
|
||||
neutralButtonText = neutralButtonTextId?.let(context::getString)
|
||||
)
|
||||
|
||||
/**
|
||||
* Shows a dialog and suspends until user interaction, returning the [AlertDialogResult].
|
||||
*
|
||||
* Note: this method will overwrite any existing dialog button click listeners or cancellation listener.
|
||||
*/
|
||||
suspend fun AlertDialog.awaitResult(
|
||||
positiveButtonText: String? = null,
|
||||
negativeButtonText: String? = null,
|
||||
neutralButtonText: String? = null
|
||||
) = suspendCancellableCoroutine { continuation ->
|
||||
|
||||
positiveButtonText?.let { text -> setButton(AlertDialog.BUTTON_POSITIVE, text) { _, _ -> continuation.resume(AlertDialogResult.POSITIVE) } }
|
||||
negativeButtonText?.let { text -> setButton(AlertDialog.BUTTON_NEGATIVE, text) { _, _ -> continuation.resume(AlertDialogResult.NEGATIVE) } }
|
||||
neutralButtonText?.let { text -> setButton(AlertDialog.BUTTON_NEUTRAL, text) { _, _ -> continuation.resume(AlertDialogResult.NEUTRAL) } }
|
||||
|
||||
setOnCancelListener { continuation.resume(AlertDialogResult.CANCELED) }
|
||||
continuation.invokeOnCancellation { dismiss() }
|
||||
|
||||
show()
|
||||
}
|
||||
|
||||
enum class AlertDialogResult {
|
||||
POSITIVE,
|
||||
NEGATIVE,
|
||||
NEUTRAL,
|
||||
CANCELED
|
||||
}
|
||||
@@ -16,19 +16,13 @@ import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.CheckBox
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.contentValuesOf
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@@ -50,37 +44,8 @@ private typealias BatchOperationNameCache = HashMap<Uri, HashSet<String>>
|
||||
* a progress dialog and is not backed by an async task.
|
||||
*/
|
||||
object SaveAttachmentUtil {
|
||||
|
||||
private val TAG = Log.tag(SaveAttachmentUtil::class.java)
|
||||
|
||||
fun showWarningDialogIfNecessary(context: Context, count: Int, onSave: () -> Unit) {
|
||||
if (SignalStore.uiHints.hasDismissedSaveStorageWarning()) {
|
||||
onSave()
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setView(R.layout.dialog_save_attachment)
|
||||
.setTitle(R.string.ConversationFragment__save_to_phone)
|
||||
.setCancelable(true)
|
||||
.setMessage(context.resources.getQuantityString(R.plurals.ConversationFragment__this_media_will_be_saved, count, count))
|
||||
.setPositiveButton(R.string.save) { dialog, _ ->
|
||||
val checkbox = (dialog as AlertDialog).findViewById<CheckBox>(R.id.checkbox)!!
|
||||
if (checkbox.isChecked) {
|
||||
SignalStore.uiHints.markDismissedSaveStorageWarning()
|
||||
}
|
||||
onSave()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
fun getAttachmentsForRecord(record: MmsMessageRecord): Set<SaveAttachment> {
|
||||
return record.slideDeck.slides
|
||||
.filter { it.uri != null && (it.hasImage() || it.hasVideo() || it.hasAudio() || it.hasDocument()) }
|
||||
.map { SaveAttachment(it.uri!!, it.contentType, record.dateSent, it.fileName.orNull()) }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
suspend fun saveAttachments(attachments: Set<SaveAttachment>): SaveAttachmentsResult {
|
||||
check(attachments.isNotEmpty()) { "must pass in at least one attachment" }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user