From 17365a50d17389c593f58624a9da6e20aba9172a Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Sun, 9 Aug 2026 01:59:17 +0300 Subject: [PATCH] Keep saving files to Downloads once 32 files share the same name MediaStore de-duplicates the display name of a new file itself, but its uniquifier gives up after 32 collisions and throws IllegalStateException. Saving then failed with "Sorry, an error occurred" and wrote nothing, and because image.png is the name most screenshots arrive with, a user who saves media regularly could reach a state where that name never saved again without deleting files from Downloads by hand. A new saveWithUniqueFileName helper in androidutils retries the insert once with a suffixed name when the platform reports it cannot build a unique one; a second failure propagates so genuine errors still surface. withFileNameSuffix builds that name by inserting the suffix before the extension, and handles names without one. Both call sites are fixed: AndroidLocalMediaActions and DefaultFileSave carried byte-identical logic and therefore the identical defect. While in AndroidLocalMediaActions, a null output uri from ContentResolver.insert now throws instead of reporting success without writing anything. The pre-Android-Q path is untouched: it overwrites rather than uniquifying, so it never reaches this limit. Relates to https://github.com/element-hq/element-x-android/issues/6371 --- .../features/viewfolder/impl/file/FileSave.kt | 24 ++--- .../libraries/androidutils/file/File.kt | 35 ++++++++ .../libraries/androidutils/file/FileTest.kt | 89 +++++++++++++++++++ .../impl/local/AndroidLocalMediaActions.kt | 24 ++--- 4 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/file/FileTest.kt diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt index 03f442f36e..add55cee36 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/FileSave.kt @@ -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) } } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index 21306ff192..dd8dc4eeb0 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -42,6 +42,41 @@ fun File.safeRenameTo(dest: File) { ) } +/** + * Returns this file name with [suffix] inserted before its extension, i.e. `"image.png"` with the + * suffix `"_1"` gives `"image_1.png"`. A name without extension simply gets the suffix appended. + */ +fun String.withFileNameSuffix(suffix: String): String { + val extension = substringAfterLast('.', "") + return if (extension.isEmpty()) { + "$this$suffix" + } else { + "${substringBeforeLast('.')}$suffix.$extension" + } +} + +/** + * Runs [saveAs] with [fileName], and runs it once more with a suffixed name if the platform failed to + * build a unique name for it. + * + * `MediaStore` de-duplicates the display name of a new file itself, but gives up after 32 conflicts + * and throws `IllegalStateException("Failed to build unique file")`, which makes saving a file with a + * common name such as `image.png` fail for good. + * See https://github.com/element-hq/element-x-android/issues/6371 + */ +fun saveWithUniqueFileName( + fileName: String, + uniqueSuffix: () -> String = { "_${System.currentTimeMillis()}" }, + saveAs: (String) -> T, +): T { + return try { + saveAs(fileName) + } catch (nameConflict: IllegalStateException) { + Timber.w(nameConflict, "Unable to build a unique file name, retrying with a suffixed one") + saveAs(fileName.withFileNameSuffix(uniqueSuffix())) + } +} + fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File { val suffix = extension?.let { ".$extension" } return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() } diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/file/FileTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/file/FileTest.kt new file mode 100644 index 0000000000..b2e28f6249 --- /dev/null +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/file/FileTest.kt @@ -0,0 +1,89 @@ +/* + * 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.libraries.androidutils.file + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class FileTest { + @Test + fun `withFileNameSuffix - inserts the suffix before the extension`() { + assertThat("image.png".withFileNameSuffix("_1")).isEqualTo("image_1.png") + } + + @Test + fun `withFileNameSuffix - appends the suffix when there is no extension`() { + assertThat("README".withFileNameSuffix("_1")).isEqualTo("README_1") + } + + @Test + fun `withFileNameSuffix - only considers the last extension`() { + assertThat("archive.tar.gz".withFileNameSuffix("_1")).isEqualTo("archive.tar_1.gz") + } + + @Test + fun `withFileNameSuffix - handles a name starting with a dot`() { + assertThat(".hidden".withFileNameSuffix("_1")).isEqualTo("_1.hidden") + } + + @Test + fun `saveWithUniqueFileName - uses the original name when it works`() { + val usedNames = mutableListOf() + + val result = saveWithUniqueFileName( + fileName = "image.png", + uniqueSuffix = { "_1" }, + ) { name -> + usedNames.add(name) + "saved as $name" + } + + assertThat(result).isEqualTo("saved as image.png") + assertThat(usedNames).containsExactly("image.png") + } + + @Test + fun `saveWithUniqueFileName - retries with a suffixed name when the name cannot be made unique`() { + val usedNames = mutableListOf() + + val result = saveWithUniqueFileName( + fileName = "image.png", + uniqueSuffix = { "_1" }, + ) { name -> + usedNames.add(name) + if (name == "image.png") { + error("Failed to build unique file") + } + "saved as $name" + } + + assertThat(result).isEqualTo("saved as image_1.png") + assertThat(usedNames).containsExactly("image.png", "image_1.png").inOrder() + } + + @Test + fun `saveWithUniqueFileName - does not retry more than once`() { + val usedNames = mutableListOf() + + val error = try { + saveWithUniqueFileName( + fileName = "image.png", + uniqueSuffix = { "_1" }, + ) { name -> + usedNames.add(name) + error("Failed to build unique file") + } + null + } catch (nameConflict: IllegalStateException) { + nameConflict + } + + assertThat(error).isInstanceOf(IllegalStateException::class.java) + assertThat(usedNames).containsExactly("image.png", "image_1.png").inOrder() + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt index 071e0dba64..4364020052 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaActions.kt @@ -32,6 +32,7 @@ import androidx.core.content.PermissionChecker import androidx.core.net.toFile 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.startInstallFromSourceIntent import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions @@ -44,6 +45,7 @@ import kotlinx.coroutines.withContext import timber.log.Timber import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.io.InputStream @ContributesBinding(AppScope::class) @@ -172,18 +174,18 @@ class AndroidLocalMediaActions( @RequiresApi(Build.VERSION_CODES.Q) private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) { - val contentValues = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.filename) - put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType) - 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) { - localMedia.openStream()?.use { input -> - resolver.openOutputStream(outputUri).use { output -> - input.copyTo(output!!, DEFAULT_BUFFER_SIZE) - } + val outputUri = saveWithUniqueFileName(localMedia.info.filename) { displayName -> + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType) + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + } + resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues) + } ?: throw IOException("Unable to create the destination file") + localMedia.openStream()?.use { input -> + resolver.openOutputStream(outputUri).use { output -> + input.copyTo(output!!, DEFAULT_BUFFER_SIZE) } } }