From 4558590492a263b554d2a03a68195b5ce9171e34 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:49:15 +0300 Subject: [PATCH] Cover both MediaStore save paths with a fake provider DefaultFileSave had no test at all and AndroidLocalMediaActions only exercised the failing path, so neither the uniquifying retry nor the new IOException on a null insert was ever executed. FakeMediaStoreContentProvider stands in for the media authority: it records the display names an insert is attempted with, keeps whatever is copied into it, and can either collide a name the way MediaStore does past 32 files or refuse to create anything. Both call sites now go through it for the plain save, the retry and the rejected insert. Its name cannot follow the Fake convention because it extends the framework ContentProvider, so it joins the existing exceptions in KonsistClassNameTest next to FakeFileSystem and FakeKeyStore. --- .../impl/file/DefaultFileSaveTest.kt | 60 ++++++++++++++++ .../local/AndroidLocalMediaActionsTest.kt | 41 +++++++++++ .../tests/konsist/KonsistClassNameTest.kt | 1 + .../fake/FakeMediaStoreContentProvider.kt | 72 +++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/DefaultFileSaveTest.kt create mode 100644 tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt diff --git a/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/DefaultFileSaveTest.kt b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/DefaultFileSaveTest.kt new file mode 100644 index 0000000000..a991a11f76 --- /dev/null +++ b/features/viewfolder/impl/src/test/kotlin/io/element/android/features/viewfolder/impl/file/DefaultFileSaveTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.viewfolder.impl.file + +import com.google.common.truth.Truth.assertThat +import io.element.android.tests.testutils.fake.registerFakeMediaStoreContentProvider +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.robolectric.RuntimeEnvironment +import java.io.File + +class DefaultFileSaveTest : RobolectricTest() { + @Test + fun `save inserts the file in the Downloads collection under its own name`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider() + createFileSave().save(aFileToSave().path) + assertThat(mediaStore.insertedDisplayNames).containsExactly("a-file.txt") + assertThat(mediaStore.savedContent("a-file.txt")).isEqualTo("Some content") + } + + @Test + fun `save retries with a suffixed name when the name cannot be made unique`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider().apply { + conflictingDisplayNames = setOf("a-file.txt") + } + createFileSave().save(aFileToSave().path) + assertThat(mediaStore.insertedDisplayNames).hasSize(2) + assertThat(mediaStore.insertedDisplayNames.last()).matches("a-file_\\d+\\.txt") + assertThat(mediaStore.savedContent(mediaStore.insertedDisplayNames.last())).isEqualTo("Some content") + } + + @Test + fun `save writes nothing when the destination cannot be created`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider().apply { + returnsNoUriOnInsert = true + } + createFileSave().save(aFileToSave().path) + assertThat(mediaStore.insertedDisplayNames).containsExactly("a-file.txt") + assertThat(mediaStore.savedFileNames()).isEmpty() + } + + private fun aFileToSave(): File { + return File(RuntimeEnvironment.getApplication().cacheDir, "a-file.txt").apply { + writeText("Some content") + } + } + + private fun TestScope.createFileSave() = DefaultFileSave( + context = RuntimeEnvironment.getApplication(), + dispatchers = testCoroutineDispatchers(), + ) +} diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt index cdc5f7220d..ab1b193e26 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActionsTest.kt @@ -12,18 +12,22 @@ import android.net.Uri import androidx.activity.compose.LocalActivityResultRegistryOwner import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia +import io.element.android.tests.testutils.fake.registerFakeMediaStoreContentProvider import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test import org.robolectric.RuntimeEnvironment +import java.io.File +import java.io.IOException class AndroidLocalMediaActionsTest : RobolectricTest() { @Test @@ -62,6 +66,43 @@ class AndroidLocalMediaActionsTest : RobolectricTest() { assertThat(result.exceptionOrNull()).isNotNull() } + @Test + fun `test AndroidLocalMediaAction save on disk inserts the media under its own name`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider() + val sut = createAndroidLocalMediaActions() + val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri())) + assertThat(result.exceptionOrNull()).isNull() + assertThat(mediaStore.insertedDisplayNames).containsExactly("an image file.jpg") + } + + @Test + fun `test AndroidLocalMediaAction save on disk retries with a suffixed name when the name cannot be made unique`() = runTest { + val mediaStore = registerFakeMediaStoreContentProvider().apply { + conflictingDisplayNames = setOf("an image file.jpg") + } + val sut = createAndroidLocalMediaActions() + val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri())) + assertThat(result.exceptionOrNull()).isNull() + assertThat(mediaStore.insertedDisplayNames).hasSize(2) + assertThat(mediaStore.insertedDisplayNames.last()).matches("an image file_\\d+\\.jpg") + } + + @Test + fun `test AndroidLocalMediaAction save on disk fails when the destination cannot be created`() = runTest { + registerFakeMediaStoreContentProvider().apply { + returnsNoUriOnInsert = true + } + val sut = createAndroidLocalMediaActions() + val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri())) + assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java) + } + + private fun aMediaFile(): File { + return File(RuntimeEnvironment.getApplication().cacheDir, "an image file.jpg").apply { + writeText("Some content") + } + } + private fun TestScope.createAndroidLocalMediaActions() = AndroidLocalMediaActions( RuntimeEnvironment.getApplication(), testCoroutineDispatchers(), diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index 93b3200acc..ddb5ba95b3 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -104,6 +104,7 @@ class KonsistClassNameTest { "FakeImageLoader", "FakeKeyStore", "FakeListenableFuture", + "FakeMediaStoreContentProvider", ) .assertTrue { val interfaceName = it.name diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt new file mode 100644 index 0000000000..4a1ffcda29 --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeMediaStoreContentProvider.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.tests.testutils.fake + +import android.content.ContentProvider +import android.content.ContentValues +import android.content.pm.ProviderInfo +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import androidx.test.core.app.ApplicationProvider +import org.robolectric.shadows.ShadowContentResolver +import java.io.File +import java.nio.file.Files + +/** + * Stands in for the MediaStore provider: it records the display names an insert is attempted with, + * writes what is copied into it to a temporary directory, and can make any display name collide the + * way MediaStore does once a directory already holds 32 files with the same name. + */ +class FakeMediaStoreContentProvider : ContentProvider() { + private val directory: File = Files.createTempDirectory("fake-media-store").toFile() + + val insertedDisplayNames = mutableListOf() + + var conflictingDisplayNames: Set = emptySet() + + /** When true, an insert reports that it created nothing, the way a rejected insert does. */ + var returnsNoUriOnInsert: Boolean = false + + fun savedFileNames(): List = directory.list().orEmpty().toList() + + fun savedContent(displayName: String): String = File(directory, displayName).readText() + + override fun onCreate() = true + + override fun insert(uri: Uri, values: ContentValues?): Uri? { + val displayName = values?.getAsString(MediaStore.MediaColumns.DISPLAY_NAME).orEmpty() + insertedDisplayNames += displayName + check(displayName !in conflictingDisplayNames) { "Failed to build unique file" } + return if (returnsNoUriOnInsert) null else uri.buildUpon().appendPath(displayName).build() + } + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor { + val file = File(directory, uri.lastPathSegment.orEmpty()) + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_CREATE or ParcelFileDescriptor.MODE_READ_WRITE) + } + + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 +} + +fun registerFakeMediaStoreContentProvider(): FakeMediaStoreContentProvider { + return FakeMediaStoreContentProvider().apply { + attachInfo( + ApplicationProvider.getApplicationContext(), + ProviderInfo().apply { authority = MediaStore.AUTHORITY } + ) + ShadowContentResolver.registerProviderInternal(MediaStore.AUTHORITY, this) + } +}