diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt index 21ab66700b..b056d472a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt @@ -6,7 +6,6 @@ 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 @@ -27,6 +26,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.util.SaveAttachmentUtil import org.thoughtcrime.securesms.util.SaveAttachmentUtil.SaveAttachment +import org.thoughtcrime.securesms.util.SaveAttachmentUtil.SaveAttachmentsResult import org.thoughtcrime.securesms.util.StorageUtil import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -63,6 +63,7 @@ class AttachmentSaver(private val host: Host) { saveToStorage(attachments) } else { Log.d(TAG, "Cancel saving ${attachments.size} attachments: media store permission denied.") + host.showSaveResult(SaveAttachmentsResult.WriteStoragePermissionDenied) } } else { Log.d(TAG, "Cancel saving ${attachments.size} attachments: save to storage warning denied.") @@ -83,12 +84,12 @@ class AttachmentSaver(private val host: Host) { return host.requestWriteExternalStoragePermission() } - private suspend fun saveToStorage(attachments: Set): SaveAttachmentUtil.SaveAttachmentsResult { + private suspend fun saveToStorage(attachments: Set): SaveAttachmentsResult { host.showSaveProgress(attachmentCount = attachments.size) return try { val result = SaveAttachmentUtil.saveAttachments(attachments) withContext(SignalDispatchers.Main) { - host.showToast { context -> result.getMessage(context) } + host.showSaveResult(result) } result } finally { @@ -101,23 +102,23 @@ class AttachmentSaver(private val host: Host) { interface Host { suspend fun showSaveToStorageWarning(attachmentCount: Int): SaveToStorageWarningResult suspend fun requestWriteExternalStoragePermission(): RequestPermissionResult - fun showToast(getMessage: (Context) -> CharSequence) fun showSaveProgress(attachmentCount: Int) + fun showSaveResult(result: SaveAttachmentsResult) fun dismissSaveProgress() } 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 fun showSaveResult(result: SaveAttachmentsResult) { + Toast.makeText(fragment.requireContext(), result.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) + .setTitle(R.string.AttachmentSaver__save_to_phone) .setCancelable(true) - .setMessage(fragment.resources.getQuantityString(R.plurals.ConversationFragment__this_media_will_be_saved, attachmentCount, attachmentCount)) + .setMessage(fragment.resources.getQuantityString(R.plurals.AttachmentSaver__this_media_will_be_saved, attachmentCount, attachmentCount)) .create() val result = dialog.awaitResult( @@ -140,7 +141,7 @@ class AttachmentSaver(private val host: Host) { 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)) + .withPermanentDenialDialog(fragment.getString(R.string.AttachmentSaver__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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java index 726b9846a3..28c765f011 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaActions.java @@ -47,8 +47,8 @@ final class MediaActions { SaveAttachmentTask.showWarningDialogIfNecessary(context, mediaRecords.size(), () -> 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(() -> Toast.makeText(context, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) + .withPermanentDenialDialog(fragment.getString(R.string.AttachmentSaver__signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied)) + .onAnyDenied(() -> Toast.makeText(context, R.string.AttachmentSaver__unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show()) .onAllGranted(() -> performSaveToDisk(context, mediaRecords, postExecute)) .execute()); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt index 66894fa059..8f1701c9c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt @@ -49,19 +49,19 @@ object StoryContextMenu { ).map { (_, deletedThread) -> deletedThread } } - suspend fun save(host: AttachmentSaver.Host, messageRecord: MessageRecord) { + suspend fun save(fragment: Fragment, messageRecord: MessageRecord) { val mediaMessageRecord = messageRecord as? MmsMessageRecord val uri: Uri? = mediaMessageRecord?.slideDeck?.firstSlide?.uri val contentType: String? = mediaMessageRecord?.slideDeck?.firstSlide?.contentType when { - mediaMessageRecord?.storyType?.isTextStory == true -> saveTextStory(host, mediaMessageRecord) - uri == null || contentType == null -> showErrorCantSaveStory(host, uri, contentType) - else -> saveMediaStory(host, uri, contentType, mediaMessageRecord) + mediaMessageRecord?.storyType?.isTextStory == true -> saveTextStory(fragment, mediaMessageRecord) + uri == null || contentType == null -> showErrorCantSaveStory(fragment, uri, contentType) + else -> saveMediaStory(fragment, uri, contentType, mediaMessageRecord) } } - private suspend fun saveTextStory(host: AttachmentSaver.Host, messageRecord: MmsMessageRecord) { + private suspend fun saveTextStory(fragment: Fragment, messageRecord: MmsMessageRecord) { val saveAttachment = withContext(Dispatchers.Main) { val model = StoryTextPostModel.parseFrom(messageRecord) val decoder = StoryTextPostModel.Decoder() @@ -78,17 +78,17 @@ object StoryContextMenu { ) } - AttachmentSaver(host).saveAttachments(setOf(saveAttachment)) + AttachmentSaver(fragment).saveAttachments(setOf(saveAttachment)) } - private suspend fun saveMediaStory(host: AttachmentSaver.Host, uri: Uri, contentType: String, mediaMessageRecord: MmsMessageRecord) { + private suspend fun saveMediaStory(fragment: Fragment, uri: Uri, contentType: String, mediaMessageRecord: MmsMessageRecord) { val saveAttachment = SaveAttachmentUtil.SaveAttachment(uri = uri, contentType = contentType, date = mediaMessageRecord.dateSent, fileName = null) - AttachmentSaver(host).saveAttachments(setOf(saveAttachment)) + AttachmentSaver(fragment).saveAttachments(setOf(saveAttachment)) } - private fun showErrorCantSaveStory(host: AttachmentSaver.Host, uri: Uri?, contentType: String?) { + private fun showErrorCantSaveStory(fragment: Fragment, uri: Uri?, contentType: String?) { Log.w(TAG, "Unable to save story media uri: $uri contentType: $contentType") - host.showToast { context -> context.getString(R.string.MyStories__unable_to_save) } + Toast.makeText(fragment.requireContext(), R.string.MyStories__unable_to_save, Toast.LENGTH_LONG).show() } fun share(fragment: Fragment, messageRecord: MmsMessageRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index 9bd0f8de0a..a2b684f5ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.launch import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.attachments.AttachmentSaver import org.thoughtcrime.securesms.banner.BannerManager import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner @@ -331,7 +330,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l onSave = { lifecycleScope.launch { StoryContextMenu.save( - host = AttachmentSaver.FragmentHost(this@StoriesLandingFragment), + fragment = this@StoriesLandingFragment, messageRecord = it.data.primaryStory.messageRecord ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt index 8eff6560bd..0dde4c044a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt @@ -10,7 +10,6 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.attachments.AttachmentSaver import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText @@ -84,7 +83,7 @@ class MyStoriesFragment : DSLSettingsFragment( onSaveClick = { lifecycleScope.launch { StoryContextMenu.save( - host = AttachmentSaver.FragmentHost(this@MyStoriesFragment), + fragment = this@MyStoriesFragment, messageRecord = it.distributionStory.messageRecord ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 4c6489a0df..ecb2c6a06e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -49,7 +49,6 @@ import org.signal.core.util.getParcelableCompat import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.animation.AnimationCompleteListener -import org.thoughtcrime.securesms.attachments.AttachmentSaver import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.components.segmentedprogressbar.SegmentedProgressBar @@ -1200,7 +1199,7 @@ class StoryViewerPageFragment : lifecycleScope.launch { viewModel.setIsSavingMedia(true) StoryContextMenu.save( - host = AttachmentSaver.FragmentHost(this@StoryViewerPageFragment), + fragment = this@StoryViewerPageFragment, messageRecord = it.conversationMessage.messageRecord ) viewModel.setIsSavingMedia(false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java index fcc385dd5d..2d807d6339 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentTask.java @@ -447,9 +447,9 @@ public class SaveAttachmentTask extends ProgressDialogAsyncTask { CheckBox checkbox = ((AlertDialog) dialog).findViewById(R.id.checkbox); if (checkbox.isChecked()) { 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 90afdb8556..33739cac5b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SaveAttachmentUtil.kt @@ -21,6 +21,7 @@ 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.logging.logI import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.mms.PartAuthority @@ -50,21 +51,20 @@ object SaveAttachmentUtil { check(attachments.isNotEmpty()) { "must pass in at least one attachment" } if (!StorageUtil.canWriteToMediaStore()) { - return SaveAttachmentsResult.ErrorNoWriteAccess(errorCount = attachments.size) + return SaveAttachmentsResult.ErrorNoWriteAccess } val nameCache: BatchOperationNameCache = HashMap() - val (successes, errors) = attachments + val (successes, failures) = attachments .map { saveAttachment(it, nameCache) } .partition { saveResult -> saveResult is SaveAttachmentResult.Success } - return SaveAttachmentsResult.Completed( - successCount = successes.size, - errorCount = errors.size - ).also { - Log.i(TAG, "Save attachments completed (${it.successCount} of ${attachments.size} saved successfully).") - } + return when { + failures.isEmpty() -> SaveAttachmentsResult.Success(successesCount = successes.size) + successes.isEmpty() -> SaveAttachmentsResult.Failure(failuresCount = failures.size) + else -> SaveAttachmentsResult.PartialSuccess(successesCount = successes.size, failuresCount = failures.size) + }.logI(TAG, "Save attachments completed (${successes.size} of ${attachments.size} saved successfully).") } private suspend fun saveAttachment(attachment: SaveAttachment, nameCache: BatchOperationNameCache): SaveAttachmentResult = withContext(Dispatchers.IO) { @@ -284,38 +284,39 @@ object SaveAttachmentUtil { } sealed interface SaveAttachmentsResult { - val successCount: Int - val errorCount: Int - fun getMessage(context: Context): CharSequence - data class Completed( - override val successCount: Int, - override val errorCount: Int - ) : SaveAttachmentsResult { - + data class Success(val successesCount: Int) : SaveAttachmentsResult { override fun getMessage(context: Context): CharSequence { - return when { - errorCount == 0 -> context.resources.getQuantityText(R.plurals.SaveAttachment_saved_success, successCount) - successCount == 0 -> context.resources.getQuantityText(R.plurals.SaveAttachment_error_while_saving_attachments_to_sd_card, errorCount) - else -> { - val numberFormat = NumberFormat.getInstance() - context.resources.getQuantityString( - R.plurals.SaveAttachment_saved_success_n_failures, - errorCount, - numberFormat.format(errorCount), - numberFormat.format(errorCount + successCount) - ) - } - } + return context.resources.getQuantityText(R.plurals.SaveAttachment_saved_success, successesCount) } } - data class ErrorNoWriteAccess( - override val errorCount: Int - ) : SaveAttachmentsResult { - override val successCount: Int = 0 + data class PartialSuccess(val successesCount: Int, val failuresCount: Int) : SaveAttachmentsResult { + override fun getMessage(context: Context): CharSequence { + val numberFormat = NumberFormat.getInstance() + return context.resources.getQuantityString( + R.plurals.SaveAttachment_saved_success_n_failures, + failuresCount, + numberFormat.format(failuresCount), + numberFormat.format(failuresCount + successesCount) + ) + } + } + data class Failure(val failuresCount: Int) : SaveAttachmentsResult { + override fun getMessage(context: Context): CharSequence { + return context.resources.getQuantityText(R.plurals.SaveAttachment_error_while_saving_attachments_to_sd_card, failuresCount) + } + } + + data object WriteStoragePermissionDenied : SaveAttachmentsResult { + override fun getMessage(context: Context): CharSequence { + return context.getString(R.string.AttachmentSaver__unable_to_write_to_external_storage_without_permission) + } + } + + data object ErrorNoWriteAccess : SaveAttachmentsResult { override fun getMessage(context: Context): CharSequence { return context.getString(R.string.SaveAttachment_unable_to_write_to_sd_card_exclamation) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a6aa9e24e8..cfd28f9fbe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -581,13 +581,6 @@ Saving attachment to storage… Saving %1$d attachments to storage… - - Save to phone? - - - This media will be saved to your phone\'s storage. Other apps may be able to access it depending on your phone\'s permissions. - This media will be saved to your phone\'s storage. Other apps may be able to access it depending on your phone\'s permissions. - Don\'t show again Pending… @@ -2997,8 +2990,6 @@ You Unsupported media type Draft - Signal needs the Storage permission in order to save to external storage, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Storage\". - Unable to save to external storage without permissions Delete message? This will permanently delete this message. %1$s to %2$s @@ -3105,8 +3096,6 @@ New linked device - - Quick response unavailable when Signal is locked! Problem sending message! @@ -3133,6 +3122,21 @@ Error while saving attachments to storage! + + Save to phone? + + + + This media will be saved to your phone\'s storage. Other apps may be able to access it depending on your phone\'s permissions. + This media will be saved to your phone\'s storage. Other apps may be able to access it depending on your phone\'s permissions. + + + + Signal needs the Storage permission in order to save to external storage, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Storage\". + + + Unable to save to external storage without permissions + Search diff --git a/app/src/test/java/org/thoughtcrime/securesms/attachments/AttachmentSaverTest.kt b/app/src/test/java/org/thoughtcrime/securesms/attachments/AttachmentSaverTest.kt index 6d7cb42f3b..b36b504a95 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/attachments/AttachmentSaverTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/attachments/AttachmentSaverTest.kt @@ -9,6 +9,7 @@ import android.app.Application import android.net.Uri import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -62,6 +63,37 @@ class AttachmentSaverTest { ) ) + private fun setUpTestEnvironment( + hasDismissedSaveStorageWarning: Boolean, + canWriteToMediaStore: Boolean, + saveWarningResult: SaveToStorageWarningResult = SaveToStorageWarningResult.ACCEPTED, + writeExternalStoragePermissionResult: RequestPermissionResult = RequestPermissionResult.GRANTED, + saveAttachmentsResult: SaveAttachmentsResult = SaveAttachmentsResult.Success(successesCount = 2) + ): TestEnvironment { + val host = mockk(relaxUnitFun = true) { + coEvery { showSaveToStorageWarning(attachmentCount = any()) } returns saveWarningResult + coEvery { requestWriteExternalStoragePermission() } returns writeExternalStoragePermissionResult + } + + val uiHints = mockk { + every { hasDismissedSaveStorageWarning() } returns hasDismissedSaveStorageWarning + } + + mockkObject(SignalStore) + every { SignalStore.uiHints } returns uiHints + + mockkStatic(StorageUtil::class) + every { StorageUtil.canWriteToMediaStore() } returns canWriteToMediaStore + + mockkObject(SaveAttachmentUtil) + coEvery { SaveAttachmentUtil.saveAttachments(any()) } returns saveAttachmentsResult + + return TestEnvironment( + host = host, + uiHints = uiHints + ) + } + @After fun tearDown() { unmockkObject(SignalStore) @@ -71,253 +103,256 @@ class AttachmentSaverTest { @Test fun `saveAttachments shows save to storage warning when it has not been dismissed`() = runTest(testDispatcher) { - val host = mockk { - coEvery { showSaveToStorageWarning(attachmentCount = any()) } returns mockk() - } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns false - } + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = false, + canWriteToMediaStore = true + ) - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify { host.showSaveToStorageWarning(attachmentCount = 2) } + coVerify { testEnv.host.showSaveToStorageWarning(attachmentCount = 2) } } @Test fun `saveAttachments does not show save to storage warning when it has been dismissed`() = runTest(testDispatcher) { - val host = mockk(relaxUnitFun = true) { - coEvery { requestWriteExternalStoragePermission() } returns mockk() - } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns true - } + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = true + ) - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify(exactly = 0) { host.showSaveToStorageWarning(attachmentCount = any()) } + coVerify(exactly = 0) { testEnv.host.showSaveToStorageWarning(attachmentCount = any()) } } @Test fun `saveAttachments requests WRITE_EXTERNAL_STORAGE permission when not yet granted and save to storage warning has been dismissed`() = runTest(testDispatcher) { - val host = mockk { - coEvery { requestWriteExternalStoragePermission() } returns mockk() - } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns true - } + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = false + ) - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) - mockkStatic(StorageUtil::class) - every { StorageUtil.canWriteToMediaStore() } returns false - - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify { host.requestWriteExternalStoragePermission() } + coVerify { testEnv.host.requestWriteExternalStoragePermission() } } @Test fun `saveAttachments requests WRITE_EXTERNAL_STORAGE permission when not yet granted and save to storage warning is accepted`() = runTest(testDispatcher) { - val host = mockk(relaxUnitFun = true) { - coEvery { showSaveToStorageWarning(attachmentCount = any()) } returns SaveToStorageWarningResult.ACCEPTED - coEvery { requestWriteExternalStoragePermission() } returns mockk() - } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns false - } + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = false, + canWriteToMediaStore = false, + saveWarningResult = SaveToStorageWarningResult.ACCEPTED + ) - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) - mockkStatic(StorageUtil::class) - every { StorageUtil.canWriteToMediaStore() } returns false - - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify { host.requestWriteExternalStoragePermission() } + coVerify { testEnv.host.requestWriteExternalStoragePermission() } } @Test fun `saveAttachments does not request WRITE_EXTERNAL_STORAGE permission when not yet granted and save to storage warning is denied`() = runTest(testDispatcher) { - val host = mockk { - coEvery { showSaveToStorageWarning(attachmentCount = any()) } returns SaveToStorageWarningResult.DENIED - } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns false - } + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = false, + canWriteToMediaStore = false, + saveWarningResult = SaveToStorageWarningResult.DENIED + ) - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify(exactly = 0) { host.requestWriteExternalStoragePermission() } + coVerify(exactly = 0) { testEnv.host.requestWriteExternalStoragePermission() } } @Test - fun `saveAttachments does not request WRITE_EXTERNAL_STORAGE permission when already granted`() = runTest(testDispatcher) { - val host = mockk(relaxUnitFun = true) - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns true - } + fun `saveAttachments does not request WRITE_EXTERNAL_STORAGE permission when canWriteToMediaStore = true`() = runTest(testDispatcher) { + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = true + ) - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) - mockkStatic(StorageUtil::class) - every { StorageUtil.canWriteToMediaStore() } returns true - - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify(exactly = 0) { host.requestWriteExternalStoragePermission() } + coVerify(exactly = 0) { testEnv.host.requestWriteExternalStoragePermission() } } @Test fun `saveAttachments does not perform save when save to storage warning is denied`() = runTest(testDispatcher) { - val host = mockk { - coEvery { showSaveToStorageWarning(attachmentCount = any()) } returns SaveToStorageWarningResult.DENIED + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = false, + canWriteToMediaStore = true, + saveWarningResult = SaveToStorageWarningResult.DENIED + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + coVerify(exactly = 0) { + SaveAttachmentUtil.saveAttachments(attachments = any()) + testEnv.host.showSaveProgress(any()) + testEnv.host.showSaveResult(SaveAttachmentsResult.Success(successesCount = 2)) + testEnv.host.dismissSaveProgress() } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns false - } - - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints - - mockkStatic(StorageUtil::class) - every { StorageUtil.canWriteToMediaStore() } returns true - - mockkObject(SaveAttachmentUtil) - - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify(exactly = 0) { SaveAttachmentUtil.saveAttachments(attachments = any()) } - verify(exactly = 0) { host.showSaveProgress(any()) } - verify(exactly = 0) { host.dismissSaveProgress() } } @Test fun `saveAttachments does not perform save when WRITE_EXTERNAL_STORAGE permission is denied`() = runTest(testDispatcher) { - val host = mockk { - coEvery { requestWriteExternalStoragePermission() } returns RequestPermissionResult.DENIED + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = false, + canWriteToMediaStore = false, + writeExternalStoragePermissionResult = RequestPermissionResult.DENIED + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + coVerify(exactly = 0) { + SaveAttachmentUtil.saveAttachments(attachments = any()) + testEnv.host.showSaveProgress(any()) + testEnv.host.dismissSaveProgress() } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns true - } - - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints - - mockkStatic(StorageUtil::class) - every { StorageUtil.canWriteToMediaStore() } returns false - - mockkObject(SaveAttachmentUtil) - - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify(exactly = 0) { SaveAttachmentUtil.saveAttachments(attachments = any()) } - verify(exactly = 0) { host.showSaveProgress(any()) } - verify(exactly = 0) { host.dismissSaveProgress() } + verify { testEnv.host.showSaveResult(SaveAttachmentsResult.WriteStoragePermissionDenied) } } @Test fun `saveAttachments performs save when save storage warning is accepted and WRITE_EXTERNAL_STORAGE permission is granted`() = runTest(testDispatcher) { - val host = mockk(relaxUnitFun = true) { - coEvery { showSaveToStorageWarning(attachmentCount = 2) } returns SaveToStorageWarningResult.ACCEPTED - coEvery { requestWriteExternalStoragePermission() } returns RequestPermissionResult.GRANTED + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = false, + canWriteToMediaStore = false, + saveWarningResult = SaveToStorageWarningResult.ACCEPTED, + writeExternalStoragePermissionResult = RequestPermissionResult.GRANTED + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + coVerifyOrder { + testEnv.host.showSaveProgress(attachmentCount = 2) + SaveAttachmentUtil.saveAttachments(attachments = testAttachments) + testEnv.host.showSaveResult(SaveAttachmentsResult.Success(successesCount = 2)) + testEnv.host.dismissSaveProgress() } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns false - } - - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints - - mockkStatic(StorageUtil::class) - every { StorageUtil.canWriteToMediaStore() } returns false - - mockkObject(SaveAttachmentUtil) - - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify { SaveAttachmentUtil.saveAttachments(attachments = any()) } - verify { host.showSaveProgress(attachmentCount = 2) } - verify { host.dismissSaveProgress() } } @Test fun `saveAttachments performs save when save storage warning is dismissed and WRITE_EXTERNAL_STORAGE permission is granted`() = runTest(testDispatcher) { - val host = mockk(relaxUnitFun = true) { - coEvery { requestWriteExternalStoragePermission() } returns RequestPermissionResult.GRANTED + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = false, + writeExternalStoragePermissionResult = RequestPermissionResult.GRANTED, + saveAttachmentsResult = SaveAttachmentsResult.Success(successesCount = 2) + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + coVerifyOrder { + testEnv.host.showSaveProgress(attachmentCount = 2) + SaveAttachmentUtil.saveAttachments(attachments = testAttachments) + testEnv.host.showSaveResult(SaveAttachmentsResult.Success(successesCount = 2)) + testEnv.host.dismissSaveProgress() } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns true - } - - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints - - mockkObject(SaveAttachmentUtil) - coEvery { SaveAttachmentUtil.saveAttachments(testAttachments) } returns SaveAttachmentsResult.Completed(successCount = 2, errorCount = 0) - - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify { SaveAttachmentUtil.saveAttachments(attachments = any()) } - verify { host.showSaveProgress(attachmentCount = 2) } - verify { host.dismissSaveProgress() } } @Test - fun `saveAttachments performs save when save storage warning is accepted and hasWriteExternalStoragePermission=true`() = runTest(testDispatcher) { - val host = mockk(relaxUnitFun = true) { - coEvery { showSaveToStorageWarning(attachmentCount = 2) } returns SaveToStorageWarningResult.ACCEPTED + fun `saveAttachments performs save when save storage warning is accepted and canWriteToMediaStore = true`() = runTest(testDispatcher) { + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = false, + canWriteToMediaStore = true, + saveWarningResult = SaveToStorageWarningResult.ACCEPTED, + saveAttachmentsResult = SaveAttachmentsResult.Success(successesCount = 2) + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + coVerifyOrder { + testEnv.host.showSaveProgress(attachmentCount = 2) + SaveAttachmentUtil.saveAttachments(attachments = testAttachments) + testEnv.host.showSaveResult(SaveAttachmentsResult.Success(successesCount = 2)) + testEnv.host.dismissSaveProgress() } - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns false - } - - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints - - mockkStatic(StorageUtil::class) - every { StorageUtil.canWriteToMediaStore() } returns true - - mockkObject(SaveAttachmentUtil) - coEvery { SaveAttachmentUtil.saveAttachments(testAttachments) } returns SaveAttachmentsResult.Completed(successCount = 2, errorCount = 0) - - AttachmentSaver(host = host).saveAttachments(testAttachments) - - coVerify { SaveAttachmentUtil.saveAttachments(attachments = any()) } - verify { host.showSaveProgress(attachmentCount = 2) } - verify { host.dismissSaveProgress() } } @Test - fun `saveAttachments performs save when save storage warning is dismissed and hasWriteExternalStoragePermission=true`() = runTest(testDispatcher) { - val host = mockk(relaxUnitFun = true) - val uiHints = mockk { - every { hasDismissedSaveStorageWarning() } returns true + fun `saveAttachments performs save when save storage warning is dismissed and canWriteToMediaStore=true`() = runTest(testDispatcher) { + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = true, + saveAttachmentsResult = SaveAttachmentsResult.Success(successesCount = 2) + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + coVerifyOrder { + testEnv.host.showSaveProgress(attachmentCount = 2) + SaveAttachmentUtil.saveAttachments(attachments = testAttachments) + testEnv.host.showSaveResult(SaveAttachmentsResult.Success(successesCount = 2)) + testEnv.host.dismissSaveProgress() } + } - mockkObject(SignalStore) - every { SignalStore.uiHints } returns uiHints + @Test + fun `saveAttachments shows success result when save result is Success`() = runTest(testDispatcher) { + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = true, + saveAttachmentsResult = SaveAttachmentsResult.Success(successesCount = 2) + ) - mockkStatic(StorageUtil::class) - every { StorageUtil.canWriteToMediaStore() } returns true + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) - mockkObject(SaveAttachmentUtil) + verify { testEnv.host.showSaveResult(SaveAttachmentsResult.Success(successesCount = 2)) } + } - AttachmentSaver(host = host).saveAttachments(testAttachments) + @Test + fun `saveAttachments shows partial success result when save result is PartialSuccess`() = runTest(testDispatcher) { + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = true, + saveAttachmentsResult = SaveAttachmentsResult.PartialSuccess(successesCount = 1, failuresCount = 1) + ) - coVerify { SaveAttachmentUtil.saveAttachments(attachments = any()) } - verify { host.showSaveProgress(attachmentCount = 2) } - verify { host.dismissSaveProgress() } + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + verify { testEnv.host.showSaveResult(SaveAttachmentsResult.PartialSuccess(successesCount = 1, failuresCount = 1)) } + } + + @Test + fun `saveAttachments shows failure result when save result is Failure`() = runTest(testDispatcher) { + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = true, + saveAttachmentsResult = SaveAttachmentsResult.Failure(failuresCount = 2) + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + verify { testEnv.host.showSaveResult(SaveAttachmentsResult.Failure(failuresCount = 2)) } + } + + @Test + fun `saveAttachments shows no write access result when save result is ErrorNoWriteAccess`() = runTest(testDispatcher) { + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = false, + saveAttachmentsResult = SaveAttachmentsResult.ErrorNoWriteAccess + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + verify { testEnv.host.showSaveResult(SaveAttachmentsResult.ErrorNoWriteAccess) } + } + + @Test + fun `saveAttachments shows permission denied result when save result is WriteStoragePermissionDenied`() = runTest(testDispatcher) { + val testEnv = setUpTestEnvironment( + hasDismissedSaveStorageWarning = true, + canWriteToMediaStore = false, + saveAttachmentsResult = SaveAttachmentsResult.WriteStoragePermissionDenied + ) + + AttachmentSaver(host = testEnv.host).saveAttachments(testAttachments) + + verify { testEnv.host.showSaveResult(SaveAttachmentsResult.WriteStoragePermissionDenied) } } } + +private data class TestEnvironment( + val host: Host, + val uiHints: UiHintValues +)