diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt new file mode 100644 index 0000000000..350db69abf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt @@ -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) { + 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): 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(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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 05bfa3c258..f311122603 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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) { - 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) { viewModel.copyToClipboard(requireContext(), messageParts).subscribe().addTo(disposables) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DialogExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DialogExtensions.kt new file mode 100644 index 0000000000..b59a92b358 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DialogExtensions.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt index 0510f6a6fc..90afdb8556 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt @@ -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> * 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(R.id.checkbox)!! - if (checkbox.isChecked) { - SignalStore.uiHints.markDismissedSaveStorageWarning() - } - onSave() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - } - - fun getAttachmentsForRecord(record: MmsMessageRecord): Set { - 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): SaveAttachmentsResult { check(attachments.isNotEmpty()) { "must pass in at least one attachment" }