Skip to content

Commit 8a55b5e

Browse files
FolderDeletionHelper: Fix unintentional deletion (commons-app#6027)
* fix issue6020: prevent unintentional deletion of subfolders and non-images by custom selector Co-authored-by: Nicolas Raoul <nicolas.raoul@gmail.com>
1 parent b2810bc commit 8a55b5e

File tree

1 file changed

+76
-77
lines changed

1 file changed

+76
-77
lines changed

app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt

+76-77
Original file line numberDiff line numberDiff line change
@@ -30,22 +30,29 @@ object FolderDeletionHelper {
3030
folder: File,
3131
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>,
3232
onDeletionComplete: (Boolean) -> Unit) {
33-
val itemCount = countItemsInFolder(context, folder)
34-
val folderPath = folder.absolutePath
3533

3634
//don't show this dialog on API 30+, it's handled automatically using MediaStore
3735
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
38-
val success = deleteFolderMain(context, folder, trashFolderLauncher)
36+
val success = trashImagesInFolder(context, folder, trashFolderLauncher)
3937
onDeletionComplete(success)
40-
4138
} else {
39+
val imagePaths = listImagesInFolder(context, folder)
40+
val imageCount = imagePaths.size
41+
val folderPath = folder.absolutePath
42+
4243
AlertDialog.Builder(context)
4344
.setTitle(context.getString(R.string.custom_selector_confirm_deletion_title))
44-
.setMessage(context.getString(R.string.custom_selector_confirm_deletion_message, folderPath, itemCount))
45+
.setMessage(
46+
context.getString(
47+
R.string.custom_selector_confirm_deletion_message,
48+
folderPath,
49+
imageCount
50+
)
51+
)
4552
.setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ ->
4653

4754
//proceed with deletion if user confirms
48-
val success = deleteFolderMain(context, folder, trashFolderLauncher)
55+
val success = deleteImagesLegacy(imagePaths)
4956
onDeletionComplete(success)
5057
}
5158
.setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ ->
@@ -57,38 +64,16 @@ object FolderDeletionHelper {
5764
}
5865

5966
/**
60-
* Deletes the specified folder, handling different Android storage models based on the API
61-
*
62-
* @param context The context used to manage storage operations.
63-
* @param folder The folder to delete.
64-
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
65-
* @return `true` if the folder deletion was successful, `false` otherwise.
66-
*/
67-
private fun deleteFolderMain(
68-
context: Context,
69-
folder: File,
70-
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
71-
{
72-
return when {
73-
//for API 30 and above, use MediaStore
74-
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> trashFolderContents(context, folder, trashFolderLauncher)
75-
76-
//for API 29 ('requestLegacyExternalStorage' is set to true in Manifest)
77-
// and below use file system
78-
else -> deleteFolderLegacy(folder)
79-
}
80-
}
81-
82-
/**
83-
* Moves all contents of a specified folder to the trash on devices running
84-
* Android 11 (API level 30) and above.
67+
* Moves all images in a specified folder (but not within its subfolders) to the trash on
68+
* devices running Android 11 (API level 30) and above.
8569
*
8670
* @param context The context used to access the content resolver.
87-
* @param folder The folder whose contents are to be moved to the trash.
88-
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
71+
* @param folder The folder whose top-level images are to be moved to the trash.
72+
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash
73+
* request.
8974
* @return `true` if the trash request was initiated successfully, `false` otherwise.
9075
*/
91-
private fun trashFolderContents(
76+
private fun trashImagesInFolder(
9277
context: Context,
9378
folder: File,
9479
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
@@ -99,66 +84,74 @@ object FolderDeletionHelper {
9984
val folderPath = folder.absolutePath
10085
val urisToTrash = mutableListOf<Uri>()
10186

102-
// Use URIs specific to media items
103-
val mediaUris = listOf(
104-
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
105-
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
106-
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
107-
)
108-
109-
for (mediaUri in mediaUris) {
110-
val selection = "${MediaStore.MediaColumns.DATA} LIKE ?"
111-
val selectionArgs = arrayOf("$folderPath/%")
112-
113-
contentResolver.query(mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection,
114-
selectionArgs, null)
115-
?.use{ cursor ->
116-
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
117-
while (cursor.moveToNext()) {
118-
val id = cursor.getLong(idColumn)
119-
val fileUri = ContentUris.withAppendedId(mediaUri, id)
120-
urisToTrash.add(fileUri)
121-
}
87+
val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
88+
89+
// select images contained in the folder but not within subfolders
90+
val selection =
91+
"${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?"
92+
val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%")
93+
94+
contentResolver.query(
95+
mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection,
96+
selectionArgs, null
97+
)?.use { cursor ->
98+
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
99+
while (cursor.moveToNext()) {
100+
val id = cursor.getLong(idColumn)
101+
val fileUri = ContentUris.withAppendedId(mediaUri, id)
102+
urisToTrash.add(fileUri)
122103
}
123104
}
124105

106+
125107
//proceed with trashing if we have valid URIs
126108
if (urisToTrash.isNotEmpty()) {
127109
try {
128110
val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true)
129-
val intentSenderRequest = IntentSenderRequest.Builder(trashRequest.intentSender).build()
111+
val intentSenderRequest =
112+
IntentSenderRequest.Builder(trashRequest.intentSender).build()
130113
trashFolderLauncher.launch(intentSenderRequest)
131114
return true
132115
} catch (e: SecurityException) {
133-
Timber.tag("DeleteFolder").e(context.getString(R.string.custom_selector_error_trashing_folder_contents, e.message))
116+
Timber.tag("DeleteFolder").e(
117+
context.getString(
118+
R.string.custom_selector_error_trashing_folder_contents,
119+
e.message
120+
)
121+
)
134122
}
135123
}
136124
return false
137125
}
138126

139127

140128
/**
141-
* Counts the number of items in a specified folder, including items in subfolders.
129+
* Lists all image file paths in the specified folder, excluding any subfolders.
142130
*
143131
* @param context The context used to access the content resolver.
144-
* @param folder The folder in which to count items.
145-
* @return The total number of items in the folder.
132+
* @param folder The folder whose top-level images are to be listed.
133+
* @return A list of file paths (as Strings) pointing to the images in the specified folder.
146134
*/
147-
private fun countItemsInFolder(context: Context, folder: File): Int {
135+
private fun listImagesInFolder(context: Context, folder: File): List<String> {
148136
val contentResolver = context.contentResolver
149137
val folderPath = folder.absolutePath
150-
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
151-
val selection = "${MediaStore.Images.Media.DATA} LIKE ?"
152-
val selectionArgs = arrayOf("$folderPath/%")
153-
154-
return contentResolver.query(
155-
uri,
156-
arrayOf(MediaStore.Images.Media._ID),
157-
selection,
158-
selectionArgs,
159-
null)?.use { cursor ->
160-
cursor.count
161-
} ?: 0
138+
val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
139+
val selection =
140+
"${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?"
141+
val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%")
142+
val imagePaths = mutableListOf<String>()
143+
144+
contentResolver.query(
145+
mediaUri, arrayOf(MediaStore.MediaColumns.DATA), selection,
146+
selectionArgs, null
147+
)?.use { cursor ->
148+
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
149+
while (cursor.moveToNext()) {
150+
val imagePath = cursor.getString(dataColumn)
151+
imagePaths.add(imagePath)
152+
}
153+
}
154+
return imagePaths
162155
}
163156

164157

@@ -180,14 +173,20 @@ object FolderDeletionHelper {
180173

181174

182175
/**
183-
* Deletes a specified folder and all of its contents on devices running
176+
* Deletes a list of image files specified by their paths, on
184177
* Android 10 (API level 29) and below.
185178
*
186-
* @param folder The `File` object representing the folder to be deleted.
187-
* @return `true` if the folder and all contents were deleted successfully; `false` otherwise.
179+
* @param imagePaths A list of absolute file paths to image files that need to be deleted.
180+
* @return `true` if all the images are successfully deleted, `false` otherwise.
188181
*/
189-
private fun deleteFolderLegacy(folder: File): Boolean {
190-
return folder.deleteRecursively()
182+
private fun deleteImagesLegacy(imagePaths: List<String>): Boolean {
183+
var result = true
184+
imagePaths.forEach {
185+
val imageFile = File(it)
186+
val deleted = imageFile.exists() && imageFile.delete()
187+
result = result && deleted
188+
}
189+
return result
191190
}
192191

193192

0 commit comments

Comments
 (0)