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:
Jeffrey Starke
2025-03-20 11:48:47 -04:00
committed by Cody Henthorne
parent 86afafac31
commit b9dc5cbe4f
4 changed files with 233 additions and 69 deletions

View File

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