Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- Support for saving multiple files ([#345])

## [1.5.0] - 2025-12-16
### Changed
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@
android:label="@string/save_as">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />

<data android:mimeType="*/*" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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

class SaveAsActivity : SimpleActivity() {
private val binding by viewBinding(ActivitySaveAsBinding::inflate)
Expand All @@ -33,50 +34,184 @@ 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<Uri>(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<Uri>(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<Uri>(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<Uri>): SaveResult {
val mimeTypes = intent.getStringArrayListExtra(Intent.EXTRA_MIME_TYPES)
val savedPaths = mutableListOf<String>()
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<String>?): Boolean {
return try {
val originalFilename = getFilenameFromContentUri(source)
?: source.toString().getFilenameFromPath()
val filename = sanitizeFilename(originalFilename)

val mimeType = contentResolver.getType(source)
?: mimeTypes?.getOrNull(index)?.takeIf { it != "*/*" }
?: intent.type?.takeIf { it != "*/*" }
?: filename.getMimeType()

val inputStream = contentResolver.openInputStream(source)
?: throw IOException(getString(R.string.error, source))

val destinationPath = getAvailablePath("$destination/$filename")
val outputStream = getFileOutputStreamSync(destinationPath, mimeType, null)
?: throw IOException(getString(R.string.error, source))

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 -> {
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))
}
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)
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<string name="recents">Recents</string>
<string name="show_recents">Show recents</string>
<string name="invert_colors">Invert colors</string>
<string name="files_saved_partially">Files saved partially</string>

<!-- Open as -->
<string name="open_as">Open as</string>
Expand Down
Loading