Merge pull request #7344 from hayaksi1/fix/6371-save-file-name-conflicts

Keep saving files to Downloads once 32 files share the same name
This commit is contained in:
Benoit Marty
2026-08-27 10:01:57 +02:00
committed by GitHub
8 changed files with 317 additions and 22 deletions
@@ -16,6 +16,7 @@ import android.provider.MediaStore
import androidx.annotation.RequiresApi
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.file.saveWithUniqueFileName
import io.element.android.libraries.androidutils.system.toast
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -25,6 +26,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
interface FileSave {
suspend fun save(
@@ -61,18 +63,18 @@ class DefaultFileSave(
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveOnDiskUsingMediaStore(path: String) {
val file = File(path)
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, file.name)
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (outputUri != null) {
file.inputStream().use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
val outputUri = saveWithUniqueFileName(file.name) { displayName ->
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, MimeTypes.OctetStream)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
} ?: throw IOException("Unable to create the destination file")
file.inputStream().use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
}
}
@@ -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(),
)
}