From 293012c2196777045b54d25159421da4652363ad Mon Sep 17 00:00:00 2001 From: Jeffrey Starke Date: Thu, 20 Mar 2025 14:53:42 -0400 Subject: [PATCH] Add unit test coverage for AttachmentSaver. --- app/build.gradle.kts | 1 + .../conversation/ConversationItem.java | 1 + .../scribbles/ImageEditorFragment.java | 2 + .../attachments/AttachmentSaverTest.kt | 323 ++++++++++++++++++ .../testing/CoroutineDispatcherRule.kt | 44 +++ 5 files changed, 371 insertions(+) create mode 100644 app/src/test/java/org/thoughtcrime/securesms/attachments/AttachmentSaverTest.kt create mode 100644 app/src/test/java/org/thoughtcrime/securesms/testing/CoroutineDispatcherRule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc1bfcd446..b686a5bab4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -608,6 +608,7 @@ dependencies { testImplementation(testLibs.mockk) testImplementation(testFixtures(project(":libsignal-service"))) testImplementation(testLibs.espresso.core) + testImplementation(testLibs.kotlinx.coroutines.test) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index b4cf1cf7ba..4c6b3b2399 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -1804,6 +1804,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo if (author.equals(Recipient.self().getId())) { return context.getString(R.string.ConversationItem__reacted_to_your_story); } else { + //noinspection WrongThread return context.getString(R.string.ConversationItem__you_reacted_to_s_story, Recipient.resolved(author).getShortDisplayName(context)); } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java index 39ec306f44..ec750e7d3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.scribbles; import android.Manifest; +import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.res.Configuration; @@ -805,6 +806,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu }); } + @SuppressLint("WrongThread") @WorkerThread public @NonNull Uri renderToSingleUseBlob() { return renderToSingleUseBlob(requireContext(), imageEditorView.getModel()); diff --git a/app/src/test/java/org/thoughtcrime/securesms/attachments/AttachmentSaverTest.kt b/app/src/test/java/org/thoughtcrime/securesms/attachments/AttachmentSaverTest.kt new file mode 100644 index 0000000000..6d7cb42f3b --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/attachments/AttachmentSaverTest.kt @@ -0,0 +1,323 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.attachments + +import android.app.Application +import android.net.Uri +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.thoughtcrime.securesms.attachments.AttachmentSaver.Host +import org.thoughtcrime.securesms.attachments.AttachmentSaver.RequestPermissionResult +import org.thoughtcrime.securesms.attachments.AttachmentSaver.SaveToStorageWarningResult +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.keyvalue.UiHintValues +import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule +import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule +import org.thoughtcrime.securesms.util.SaveAttachmentUtil +import org.thoughtcrime.securesms.util.SaveAttachmentUtil.SaveAttachmentsResult +import org.thoughtcrime.securesms.util.StorageUtil + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, application = Application::class) +class AttachmentSaverTest { + private val testDispatcher = StandardTestDispatcher() + + @get:Rule + val coroutineDispatcherRule = CoroutineDispatcherRule(testDispatcher) + + @get:Rule + val appDependencies = MockAppDependenciesRule() + + private val testAttachments: Set = setOf( + SaveAttachmentUtil.SaveAttachment( + uri = Uri.parse("content://org.thoughtcrime.securesms/part/111"), + contentType = "image/jpeg", + date = 1742234803832, + fileName = null + ), + + SaveAttachmentUtil.SaveAttachment( + uri = Uri.parse("content://org.thoughtcrime.securesms/part/222"), + contentType = "image/jpeg", + date = 1742234384758, + fileName = null + ) + ) + + @After + fun tearDown() { + unmockkObject(SignalStore) + unmockkObject(SaveAttachmentUtil) + unmockkStatic(StorageUtil::class) + } + + @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 + } + + mockkObject(SignalStore) + every { SignalStore.uiHints } returns uiHints + + AttachmentSaver(host = host).saveAttachments(testAttachments) + + coVerify { 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 + } + + mockkObject(SignalStore) + every { SignalStore.uiHints } returns uiHints + + AttachmentSaver(host = host).saveAttachments(testAttachments) + + coVerify(exactly = 0) { 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 + } + + mockkObject(SignalStore) + every { SignalStore.uiHints } returns uiHints + + mockkStatic(StorageUtil::class) + every { StorageUtil.canWriteToMediaStore() } returns false + + AttachmentSaver(host = host).saveAttachments(testAttachments) + + coVerify { 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 + } + + mockkObject(SignalStore) + every { SignalStore.uiHints } returns uiHints + + mockkStatic(StorageUtil::class) + every { StorageUtil.canWriteToMediaStore() } returns false + + AttachmentSaver(host = host).saveAttachments(testAttachments) + + coVerify { 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 + } + + mockkObject(SignalStore) + every { SignalStore.uiHints } returns uiHints + + AttachmentSaver(host = host).saveAttachments(testAttachments) + + coVerify(exactly = 0) { 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 + } + + mockkObject(SignalStore) + every { SignalStore.uiHints } returns uiHints + + mockkStatic(StorageUtil::class) + every { StorageUtil.canWriteToMediaStore() } returns true + + AttachmentSaver(host = host).saveAttachments(testAttachments) + + coVerify(exactly = 0) { 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 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 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() } + } + + @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 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 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 + } + 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 + } + + mockkObject(SignalStore) + every { SignalStore.uiHints } returns uiHints + + mockkStatic(StorageUtil::class) + every { StorageUtil.canWriteToMediaStore() } returns true + + mockkObject(SaveAttachmentUtil) + + AttachmentSaver(host = host).saveAttachments(testAttachments) + + coVerify { SaveAttachmentUtil.saveAttachments(attachments = any()) } + verify { host.showSaveProgress(attachmentCount = 2) } + verify { host.dismissSaveProgress() } + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/testing/CoroutineDispatcherRule.kt b/app/src/test/java/org/thoughtcrime/securesms/testing/CoroutineDispatcherRule.kt new file mode 100644 index 0000000000..aff8c083b1 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/testing/CoroutineDispatcherRule.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.testing + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.TestDispatcher +import org.junit.rules.ExternalResource +import org.signal.core.util.concurrent.SignalDispatchers + +/** + * Rule that allows for injection of test dispatchers when operating with ViewModels. + */ +class CoroutineDispatcherRule( + defaultDispatcher: TestDispatcher, + mainDispatcher: TestDispatcher = defaultDispatcher, + ioDispatcher: TestDispatcher = defaultDispatcher, + unconfinedDispatcher: TestDispatcher = defaultDispatcher +) : ExternalResource() { + + private val testDispatcherProvider = TestDispatcherProvider( + main = mainDispatcher, + io = ioDispatcher, + default = defaultDispatcher, + unconfined = unconfinedDispatcher + ) + + override fun before() { + SignalDispatchers.setDispatcherProvider(testDispatcherProvider) + } + + override fun after() { + SignalDispatchers.setDispatcherProvider() + } + + private class TestDispatcherProvider( + override val main: CoroutineDispatcher, + override val io: CoroutineDispatcher, + override val default: CoroutineDispatcher, + override val unconfined: CoroutineDispatcher + ) : SignalDispatchers.DispatcherProvider +}