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

View File

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