Skip to content

Commit 49bae41

Browse files
author
Jan Guegel
committed
added "save as"-intent for multiple files
1 parent 67d77b3 commit 49bae41

File tree

4 files changed

+180
-37
lines changed

4 files changed

+180
-37
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## [Unreleased]
8+
### Added
9+
- Added multiple files "save as"-intent ([#345])
810

911
## [1.5.0] - 2025-12-16
1012
### Changed
@@ -119,6 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
119121
[#250]: https://github.com/FossifyOrg/File-Manager/issues/250
120122
[#251]: https://github.com/FossifyOrg/File-Manager/issues/251
121123
[#267]: https://github.com/FossifyOrg/File-Manager/issues/267
124+
[#345]: https://github.com/FossifyOrg/File-Manager/issues/345
122125

123126
[Unreleased]: https://github.com/FossifyOrg/File-Manager/compare/1.5.0...HEAD
124127
[1.5.0]: https://github.com/FossifyOrg/File-Manager/compare/1.4.0...1.5.0

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
android:label="@string/save_as">
120120
<intent-filter>
121121
<action android:name="android.intent.action.SEND" />
122+
<action android:name="android.intent.action.SEND_MULTIPLE" />
122123
<category android:name="android.intent.category.DEFAULT" />
123124

124125
<data android:mimeType="*/*" />

app/src/main/kotlin/org/fossify/filemanager/activities/SaveAsActivity.kt

Lines changed: 174 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import org.fossify.filemanager.R
1111
import org.fossify.filemanager.databinding.ActivitySaveAsBinding
1212
import org.fossify.filemanager.extensions.config
1313
import java.io.File
14+
import java.io.IOException
1415

16+
@Suppress("TooManyFunctions")
1517
class SaveAsActivity : SimpleActivity() {
1618
private val binding by viewBinding(ActivitySaveAsBinding::inflate)
1719

@@ -33,50 +35,185 @@ class SaveAsActivity : SimpleActivity() {
3335
}
3436

3537
private fun saveAsDialog() {
36-
if (intent.action == Intent.ACTION_SEND && intent.extras?.containsKey(Intent.EXTRA_STREAM) == true) {
37-
FilePickerDialog(this, pickFile = false, showHidden = config.shouldShowHidden(), showFAB = true, showFavoritesButton = true) {
38-
val destination = it
39-
handleSAFDialog(destination) {
40-
toast(R.string.saving)
41-
ensureBackgroundThread {
42-
try {
43-
if (!getDoesFilePathExist(destination)) {
44-
if (needsStupidWritePermissions(destination)) {
45-
val document = getDocumentFile(destination)
46-
document!!.createDirectory(destination.getFilenameFromPath())
47-
} else {
48-
File(destination).mkdirs()
49-
}
50-
}
51-
52-
val source = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)!!
53-
val originalFilename = getFilenameFromContentUri(source)
54-
?: source.toString().getFilenameFromPath()
55-
val filename = sanitizeFilename(originalFilename)
56-
val mimeType = contentResolver.getType(source)
57-
?: intent.type?.takeIf { it != "*/*" }
58-
?: filename.getMimeType()
59-
val inputStream = contentResolver.openInputStream(source)
60-
61-
val destinationPath = getAvailablePath("$destination/$filename")
62-
val outputStream = getFileOutputStreamSync(destinationPath, mimeType, null)!!
63-
inputStream!!.copyTo(outputStream)
64-
rescanPaths(arrayListOf(destinationPath))
65-
toast(R.string.file_saved)
66-
finish()
67-
} catch (e: Exception) {
68-
showErrorToast(e)
69-
finish()
70-
}
38+
when {
39+
intent.action == Intent.ACTION_SEND && intent.extras?.containsKey(Intent.EXTRA_STREAM) == true -> {
40+
handleSingleFile()
41+
}
42+
intent.action == Intent.ACTION_SEND_MULTIPLE && intent.extras?.containsKey(Intent.EXTRA_STREAM) == true -> {
43+
handleMultipleFiles()
44+
}
45+
else -> {
46+
toast(R.string.unknown_error_occurred)
47+
finish()
48+
}
49+
}
50+
}
51+
52+
private fun handleSingleFile() {
53+
FilePickerDialog(this, pickFile = false, showHidden = config.shouldShowHidden(), showFAB = true, showFavoritesButton = true) {
54+
val destination = it
55+
handleSAFDialog(destination) {
56+
toast(R.string.saving)
57+
ensureBackgroundThread {
58+
try {
59+
createDestinationIfNeeded(destination)
60+
61+
val source = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)!!
62+
val originalFilename = getFilenameFromContentUri(source)
63+
?: source.toString().getFilenameFromPath()
64+
val filename = sanitizeFilename(originalFilename)
65+
val mimeType = contentResolver.getType(source)
66+
?: intent.type?.takeIf { it != "*/*" }
67+
?: filename.getMimeType()
68+
val inputStream = contentResolver.openInputStream(source)
69+
70+
val destinationPath = getAvailablePath("$destination/$filename")
71+
val outputStream = getFileOutputStreamSync(destinationPath, mimeType, null)!!
72+
inputStream!!.copyTo(outputStream)
73+
rescanPaths(arrayListOf(destinationPath))
74+
toast(R.string.file_saved)
75+
finish()
76+
} catch (e: IOException) {
77+
showErrorToast(e)
78+
finish()
79+
} catch (e: SecurityException) {
80+
showErrorToast(e)
81+
finish()
7182
}
7283
}
7384
}
74-
} else {
75-
toast(R.string.unknown_error_occurred)
85+
}
86+
}
87+
88+
private fun handleMultipleFiles() {
89+
FilePickerDialog(this, pickFile = false, showHidden = config.shouldShowHidden(), showFAB = true, showFavoritesButton = true) { destination ->
90+
handleSAFDialog(destination) {
91+
toast(R.string.saving)
92+
ensureBackgroundThread {
93+
processMultipleFiles(destination)
94+
}
95+
}
96+
}
97+
}
98+
99+
private fun processMultipleFiles(destination: String) {
100+
try {
101+
createDestinationIfNeeded(destination)
102+
103+
val uriList = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
104+
if (uriList.isNullOrEmpty()) {
105+
runOnUiThread {
106+
toast(R.string.no_items_found)
107+
finish()
108+
}
109+
return
110+
}
111+
112+
val result = saveAllFiles(destination, uriList)
113+
showFinalResult(result)
114+
} catch (e: IOException) {
115+
runOnUiThread {
116+
showErrorToast(e)
117+
finish()
118+
}
119+
} catch (e: SecurityException) {
120+
runOnUiThread {
121+
showErrorToast(e)
122+
finish()
123+
}
124+
}
125+
}
126+
127+
private fun saveAllFiles(destination: String, uriList: ArrayList<Uri>): SaveResult {
128+
val mimeTypes = intent.getStringArrayListExtra(Intent.EXTRA_MIME_TYPES)
129+
val savedPaths = mutableListOf<String>()
130+
var successCount = 0
131+
var errorCount = 0
132+
133+
for ((index, source) in uriList.withIndex()) {
134+
if (saveSingleFileItem(destination, source, index, mimeTypes)) {
135+
successCount++
136+
savedPaths.add(destination)
137+
} else {
138+
errorCount++
139+
}
140+
}
141+
142+
if (savedPaths.isNotEmpty()) {
143+
rescanPaths(ArrayList(savedPaths))
144+
}
145+
146+
return SaveResult(successCount, errorCount)
147+
}
148+
149+
private fun saveSingleFileItem(
150+
destination: String,
151+
source: Uri,
152+
index: Int,
153+
mimeTypes: ArrayList<String>?): Boolean {
154+
return try {
155+
val originalFilename = getFilenameFromContentUri(source)
156+
?: source.toString().getFilenameFromPath()
157+
?: "file_$index"
158+
val filename = originalFilename.replace("[/\\\\<>:\"|?*\u0000-\u001F]".toRegex(), "_")
159+
.takeIf { it.isNotBlank() } ?: "unnamed_file"
160+
161+
val mimeType = contentResolver.getType(source)
162+
?: mimeTypes?.getOrNull(index)?.takeIf { it != "*/*" }
163+
?: intent.type?.takeIf { it != "*/*" }
164+
?: filename.getMimeType()
165+
166+
val inputStream = contentResolver.openInputStream(source)
167+
?: throw IOException("Cannot open input stream")
168+
169+
val destinationPath = getAvailablePath("$destination/$filename")
170+
val outputStream = getFileOutputStreamSync(destinationPath, mimeType, null)
171+
?: throw IOException("Cannot create output stream")
172+
173+
inputStream.use { input ->
174+
outputStream.use { output ->
175+
input.copyTo(output)
176+
}
177+
}
178+
true
179+
} catch (e: IOException) {
180+
showErrorToast(e)
181+
false
182+
} catch (e: SecurityException) {
183+
showErrorToast(e)
184+
false
185+
}
186+
}
187+
188+
private fun showFinalResult(result: SaveResult) {
189+
runOnUiThread {
190+
when {
191+
result.successCount > 0 && result.errorCount == 0 -> {
192+
toast(getString(R.string.file_saved))
193+
}
194+
result.successCount > 0 && result.errorCount > 0 -> {
195+
toast(getString(R.string.files_saved_partially))
196+
}
197+
else -> {
198+
toast(R.string.error)
199+
}
200+
}
76201
finish()
77202
}
78203
}
79204

205+
private data class SaveResult(val successCount: Int, val errorCount: Int)
206+
private fun createDestinationIfNeeded(destination: String) {
207+
if (!getDoesFilePathExist(destination)) {
208+
if (needsStupidWritePermissions(destination)) {
209+
val document = getDocumentFile(destination)
210+
document!!.createDirectory(destination.getFilenameFromPath())
211+
} else {
212+
File(destination).mkdirs()
213+
}
214+
}
215+
}
216+
80217
override fun onResume() {
81218
super.onResume()
82219
setupTopAppBar(binding.activitySaveAsAppbar, NavigationIcon.Arrow)

app/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
<string name="recents">Recents</string>
1414
<string name="show_recents">Show recents</string>
1515
<string name="invert_colors">Invert colors</string>
16+
<string name="files_saved">Files saved successfully</string>
17+
<string name="files_saved_partially">Files saved partially</string>
1618

1719
<!-- Open as -->
1820
<string name="open_as">Open as</string>

0 commit comments

Comments
 (0)