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) } } }