From 49bae41631257f974c5d22c3098f0d26f8963ffd Mon Sep 17 00:00:00 2001 From: Jan Guegel Date: Tue, 16 Dec 2025 19:15:04 +0100 Subject: [PATCH 1/3] added "save as"-intent for multiple files --- CHANGELOG.md | 3 + app/src/main/AndroidManifest.xml | 1 + .../filemanager/activities/SaveAsActivity.kt | 211 +++++++++++++++--- app/src/main/res/values/strings.xml | 2 + 4 files changed, 180 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53be21f6..91e5ae36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Added multiple files "save as"-intent ([#345]) ## [1.5.0] - 2025-12-16 ### Changed @@ -119,6 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#250]: https://github.com/FossifyOrg/File-Manager/issues/250 [#251]: https://github.com/FossifyOrg/File-Manager/issues/251 [#267]: https://github.com/FossifyOrg/File-Manager/issues/267 +[#345]: https://github.com/FossifyOrg/File-Manager/issues/345 [Unreleased]: https://github.com/FossifyOrg/File-Manager/compare/1.5.0...HEAD [1.5.0]: https://github.com/FossifyOrg/File-Manager/compare/1.4.0...1.5.0 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2684edd7..34c25dfe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -119,6 +119,7 @@ android:label="@string/save_as"> + diff --git a/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt b/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt index 0741dc9d..79fa5ec6 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt @@ -11,7 +11,9 @@ import org.fossify.filemanager.R import org.fossify.filemanager.databinding.ActivitySaveAsBinding import org.fossify.filemanager.extensions.config import java.io.File +import java.io.IOException +@Suppress("TooManyFunctions") class SaveAsActivity : SimpleActivity() { private val binding by viewBinding(ActivitySaveAsBinding::inflate) @@ -33,50 +35,185 @@ class SaveAsActivity : SimpleActivity() { } private fun saveAsDialog() { - if (intent.action == Intent.ACTION_SEND && intent.extras?.containsKey(Intent.EXTRA_STREAM) == true) { - FilePickerDialog(this, pickFile = false, showHidden = config.shouldShowHidden(), showFAB = true, showFavoritesButton = true) { - val destination = it - handleSAFDialog(destination) { - toast(R.string.saving) - ensureBackgroundThread { - try { - if (!getDoesFilePathExist(destination)) { - if (needsStupidWritePermissions(destination)) { - val document = getDocumentFile(destination) - document!!.createDirectory(destination.getFilenameFromPath()) - } else { - File(destination).mkdirs() - } - } - - val source = intent.getParcelableExtra(Intent.EXTRA_STREAM)!! - val originalFilename = getFilenameFromContentUri(source) - ?: source.toString().getFilenameFromPath() - val filename = sanitizeFilename(originalFilename) - val mimeType = contentResolver.getType(source) - ?: intent.type?.takeIf { it != "*/*" } - ?: filename.getMimeType() - val inputStream = contentResolver.openInputStream(source) - - val destinationPath = getAvailablePath("$destination/$filename") - val outputStream = getFileOutputStreamSync(destinationPath, mimeType, null)!! - inputStream!!.copyTo(outputStream) - rescanPaths(arrayListOf(destinationPath)) - toast(R.string.file_saved) - finish() - } catch (e: Exception) { - showErrorToast(e) - finish() - } + when { + intent.action == Intent.ACTION_SEND && intent.extras?.containsKey(Intent.EXTRA_STREAM) == true -> { + handleSingleFile() + } + intent.action == Intent.ACTION_SEND_MULTIPLE && intent.extras?.containsKey(Intent.EXTRA_STREAM) == true -> { + handleMultipleFiles() + } + else -> { + toast(R.string.unknown_error_occurred) + finish() + } + } + } + + private fun handleSingleFile() { + FilePickerDialog(this, pickFile = false, showHidden = config.shouldShowHidden(), showFAB = true, showFavoritesButton = true) { + val destination = it + handleSAFDialog(destination) { + toast(R.string.saving) + ensureBackgroundThread { + try { + createDestinationIfNeeded(destination) + + val source = intent.getParcelableExtra(Intent.EXTRA_STREAM)!! + val originalFilename = getFilenameFromContentUri(source) + ?: source.toString().getFilenameFromPath() + val filename = sanitizeFilename(originalFilename) + val mimeType = contentResolver.getType(source) + ?: intent.type?.takeIf { it != "*/*" } + ?: filename.getMimeType() + val inputStream = contentResolver.openInputStream(source) + + val destinationPath = getAvailablePath("$destination/$filename") + val outputStream = getFileOutputStreamSync(destinationPath, mimeType, null)!! + inputStream!!.copyTo(outputStream) + rescanPaths(arrayListOf(destinationPath)) + toast(R.string.file_saved) + finish() + } catch (e: IOException) { + showErrorToast(e) + finish() + } catch (e: SecurityException) { + showErrorToast(e) + finish() } } } - } else { - toast(R.string.unknown_error_occurred) + } + } + + private fun handleMultipleFiles() { + FilePickerDialog(this, pickFile = false, showHidden = config.shouldShowHidden(), showFAB = true, showFavoritesButton = true) { destination -> + handleSAFDialog(destination) { + toast(R.string.saving) + ensureBackgroundThread { + processMultipleFiles(destination) + } + } + } + } + + private fun processMultipleFiles(destination: String) { + try { + createDestinationIfNeeded(destination) + + val uriList = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + if (uriList.isNullOrEmpty()) { + runOnUiThread { + toast(R.string.no_items_found) + finish() + } + return + } + + val result = saveAllFiles(destination, uriList) + showFinalResult(result) + } catch (e: IOException) { + runOnUiThread { + showErrorToast(e) + finish() + } + } catch (e: SecurityException) { + runOnUiThread { + showErrorToast(e) + finish() + } + } + } + + private fun saveAllFiles(destination: String, uriList: ArrayList): SaveResult { + val mimeTypes = intent.getStringArrayListExtra(Intent.EXTRA_MIME_TYPES) + val savedPaths = mutableListOf() + var successCount = 0 + var errorCount = 0 + + for ((index, source) in uriList.withIndex()) { + if (saveSingleFileItem(destination, source, index, mimeTypes)) { + successCount++ + savedPaths.add(destination) + } else { + errorCount++ + } + } + + if (savedPaths.isNotEmpty()) { + rescanPaths(ArrayList(savedPaths)) + } + + return SaveResult(successCount, errorCount) + } + + private fun saveSingleFileItem( + destination: String, + source: Uri, + index: Int, + mimeTypes: ArrayList?): Boolean { + return try { + val originalFilename = getFilenameFromContentUri(source) + ?: source.toString().getFilenameFromPath() + ?: "file_$index" + val filename = originalFilename.replace("[/\\\\<>:\"|?*\u0000-\u001F]".toRegex(), "_") + .takeIf { it.isNotBlank() } ?: "unnamed_file" + + val mimeType = contentResolver.getType(source) + ?: mimeTypes?.getOrNull(index)?.takeIf { it != "*/*" } + ?: intent.type?.takeIf { it != "*/*" } + ?: filename.getMimeType() + + val inputStream = contentResolver.openInputStream(source) + ?: throw IOException("Cannot open input stream") + + val destinationPath = getAvailablePath("$destination/$filename") + val outputStream = getFileOutputStreamSync(destinationPath, mimeType, null) + ?: throw IOException("Cannot create output stream") + + inputStream.use { input -> + outputStream.use { output -> + input.copyTo(output) + } + } + true + } catch (e: IOException) { + showErrorToast(e) + false + } catch (e: SecurityException) { + showErrorToast(e) + false + } + } + + private fun showFinalResult(result: SaveResult) { + runOnUiThread { + when { + result.successCount > 0 && result.errorCount == 0 -> { + toast(getString(R.string.file_saved)) + } + result.successCount > 0 && result.errorCount > 0 -> { + toast(getString(R.string.files_saved_partially)) + } + else -> { + toast(R.string.error) + } + } finish() } } + private data class SaveResult(val successCount: Int, val errorCount: Int) + private fun createDestinationIfNeeded(destination: String) { + if (!getDoesFilePathExist(destination)) { + if (needsStupidWritePermissions(destination)) { + val document = getDocumentFile(destination) + document!!.createDirectory(destination.getFilenameFromPath()) + } else { + File(destination).mkdirs() + } + } + } + override fun onResume() { super.onResume() setupTopAppBar(binding.activitySaveAsAppbar, NavigationIcon.Arrow) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 07a68b54..b6685f80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,6 +13,8 @@ Recents Show recents Invert colors + Files saved successfully + Files saved partially Open as From e6a7542f394fe9d776d6756e2795ea8938bcfb49 Mon Sep 17 00:00:00 2001 From: Jan Guegel Date: Tue, 16 Dec 2025 19:24:03 +0100 Subject: [PATCH 2/3] use sanitizeFilename for FileItem remove hardcoded string --- .../org/fossify/filemanager/activities/SaveAsActivity.kt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt b/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt index 79fa5ec6..6e69307e 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt @@ -154,9 +154,7 @@ class SaveAsActivity : SimpleActivity() { return try { val originalFilename = getFilenameFromContentUri(source) ?: source.toString().getFilenameFromPath() - ?: "file_$index" - val filename = originalFilename.replace("[/\\\\<>:\"|?*\u0000-\u001F]".toRegex(), "_") - .takeIf { it.isNotBlank() } ?: "unnamed_file" + val filename = sanitizeFilename(originalFilename) val mimeType = contentResolver.getType(source) ?: mimeTypes?.getOrNull(index)?.takeIf { it != "*/*" } @@ -164,11 +162,11 @@ class SaveAsActivity : SimpleActivity() { ?: filename.getMimeType() val inputStream = contentResolver.openInputStream(source) - ?: throw IOException("Cannot open input stream") + ?: throw IOException(getString(R.string.error, source)) val destinationPath = getAvailablePath("$destination/$filename") val outputStream = getFileOutputStreamSync(destinationPath, mimeType, null) - ?: throw IOException("Cannot create output stream") + ?: throw IOException(getString(R.string.error, source)) inputStream.use { input -> outputStream.use { output -> From 4bc827a21d3aa75998fe62a66ed394afaeeaca7d Mon Sep 17 00:00:00 2001 From: Jan Guegel Date: Mon, 22 Dec 2025 09:19:46 +0100 Subject: [PATCH 3/3] apply code review feedback --- CHANGELOG.md | 2 +- .../org/fossify/filemanager/activities/SaveAsActivity.kt | 4 ++-- app/src/main/res/values/strings.xml | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e5ae36..e02b7a3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Added multiple files "save as"-intent ([#345]) +- Support for saving multiple files ([#345]) ## [1.5.0] - 2025-12-16 ### Changed diff --git a/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt b/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt index 6e69307e..e58aaa4c 100644 --- a/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt +++ b/app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt @@ -13,7 +13,6 @@ import org.fossify.filemanager.extensions.config import java.io.File import java.io.IOException -@Suppress("TooManyFunctions") class SaveAsActivity : SimpleActivity() { private val binding by viewBinding(ActivitySaveAsBinding::inflate) @@ -187,7 +186,8 @@ class SaveAsActivity : SimpleActivity() { runOnUiThread { when { result.successCount > 0 && result.errorCount == 0 -> { - toast(getString(R.string.file_saved)) + val message = resources.getQuantityString(R.plurals.files_saved,result.successCount) + toast(message) } result.successCount > 0 && result.errorCount > 0 -> { toast(getString(R.string.files_saved_partially)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6685f80..93eb8aac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -13,7 +13,6 @@ Recents Show recents Invert colors - Files saved successfully Files saved partially