FolderDeletionHelper: Fix unintentional deletion (#6027)

* fix issue6020: prevent unintentional deletion of subfolders and non-images by custom selector

Co-authored-by: Nicolas Raoul <nicolas.raoul@gmail.com>
This commit is contained in:
Tanmay Gupta 2024-12-13 19:11:47 +05:30 committed by GitHub
parent b2810bcef1
commit 8a55b5e613
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -30,22 +30,29 @@ object FolderDeletionHelper {
folder: File, folder: File,
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>, trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>,
onDeletionComplete: (Boolean) -> Unit) { onDeletionComplete: (Boolean) -> Unit) {
val itemCount = countItemsInFolder(context, folder)
val folderPath = folder.absolutePath
//don't show this dialog on API 30+, it's handled automatically using MediaStore //don't show this dialog on API 30+, it's handled automatically using MediaStore
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val success = deleteFolderMain(context, folder, trashFolderLauncher) val success = trashImagesInFolder(context, folder, trashFolderLauncher)
onDeletionComplete(success) onDeletionComplete(success)
} else { } else {
val imagePaths = listImagesInFolder(context, folder)
val imageCount = imagePaths.size
val folderPath = folder.absolutePath
AlertDialog.Builder(context) AlertDialog.Builder(context)
.setTitle(context.getString(R.string.custom_selector_confirm_deletion_title)) .setTitle(context.getString(R.string.custom_selector_confirm_deletion_title))
.setMessage(context.getString(R.string.custom_selector_confirm_deletion_message, folderPath, itemCount)) .setMessage(
context.getString(
R.string.custom_selector_confirm_deletion_message,
folderPath,
imageCount
)
)
.setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ -> .setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ ->
//proceed with deletion if user confirms //proceed with deletion if user confirms
val success = deleteFolderMain(context, folder, trashFolderLauncher) val success = deleteImagesLegacy(imagePaths)
onDeletionComplete(success) onDeletionComplete(success)
} }
.setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ -> .setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ ->
@ -57,38 +64,16 @@ object FolderDeletionHelper {
} }
/** /**
* Deletes the specified folder, handling different Android storage models based on the API * Moves all images in a specified folder (but not within its subfolders) to the trash on
* * devices running Android 11 (API level 30) and above.
* @param context The context used to manage storage operations.
* @param folder The folder to delete.
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
* @return `true` if the folder deletion was successful, `false` otherwise.
*/
private fun deleteFolderMain(
context: Context,
folder: File,
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
{
return when {
//for API 30 and above, use MediaStore
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> trashFolderContents(context, folder, trashFolderLauncher)
//for API 29 ('requestLegacyExternalStorage' is set to true in Manifest)
// and below use file system
else -> deleteFolderLegacy(folder)
}
}
/**
* Moves all contents of a specified folder to the trash on devices running
* Android 11 (API level 30) and above.
* *
* @param context The context used to access the content resolver. * @param context The context used to access the content resolver.
* @param folder The folder whose contents are to be moved to the trash. * @param folder The folder whose top-level images are to be moved to the trash.
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request. * @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash
* request.
* @return `true` if the trash request was initiated successfully, `false` otherwise. * @return `true` if the trash request was initiated successfully, `false` otherwise.
*/ */
private fun trashFolderContents( private fun trashImagesInFolder(
context: Context, context: Context,
folder: File, folder: File,
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
@ -99,38 +84,41 @@ object FolderDeletionHelper {
val folderPath = folder.absolutePath val folderPath = folder.absolutePath
val urisToTrash = mutableListOf<Uri>() val urisToTrash = mutableListOf<Uri>()
// Use URIs specific to media items val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val mediaUris = listOf(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
)
for (mediaUri in mediaUris) { // select images contained in the folder but not within subfolders
val selection = "${MediaStore.MediaColumns.DATA} LIKE ?" val selection =
val selectionArgs = arrayOf("$folderPath/%") "${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?"
val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%")
contentResolver.query(mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection, contentResolver.query(
selectionArgs, null) mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection,
?.use{ cursor -> selectionArgs, null
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) )?.use { cursor ->
while (cursor.moveToNext()) { val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
val id = cursor.getLong(idColumn) while (cursor.moveToNext()) {
val fileUri = ContentUris.withAppendedId(mediaUri, id) val id = cursor.getLong(idColumn)
urisToTrash.add(fileUri) val fileUri = ContentUris.withAppendedId(mediaUri, id)
} urisToTrash.add(fileUri)
} }
} }
//proceed with trashing if we have valid URIs //proceed with trashing if we have valid URIs
if (urisToTrash.isNotEmpty()) { if (urisToTrash.isNotEmpty()) {
try { try {
val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true) val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true)
val intentSenderRequest = IntentSenderRequest.Builder(trashRequest.intentSender).build() val intentSenderRequest =
IntentSenderRequest.Builder(trashRequest.intentSender).build()
trashFolderLauncher.launch(intentSenderRequest) trashFolderLauncher.launch(intentSenderRequest)
return true return true
} catch (e: SecurityException) { } catch (e: SecurityException) {
Timber.tag("DeleteFolder").e(context.getString(R.string.custom_selector_error_trashing_folder_contents, e.message)) Timber.tag("DeleteFolder").e(
context.getString(
R.string.custom_selector_error_trashing_folder_contents,
e.message
)
)
} }
} }
return false return false
@ -138,27 +126,32 @@ object FolderDeletionHelper {
/** /**
* Counts the number of items in a specified folder, including items in subfolders. * Lists all image file paths in the specified folder, excluding any subfolders.
* *
* @param context The context used to access the content resolver. * @param context The context used to access the content resolver.
* @param folder The folder in which to count items. * @param folder The folder whose top-level images are to be listed.
* @return The total number of items in the folder. * @return A list of file paths (as Strings) pointing to the images in the specified folder.
*/ */
private fun countItemsInFolder(context: Context, folder: File): Int { private fun listImagesInFolder(context: Context, folder: File): List<String> {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
val folderPath = folder.absolutePath val folderPath = folder.absolutePath
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI val mediaUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val selection = "${MediaStore.Images.Media.DATA} LIKE ?" val selection =
val selectionArgs = arrayOf("$folderPath/%") "${MediaStore.MediaColumns.DATA} LIKE ? AND ${MediaStore.MediaColumns.DATA} NOT LIKE ?"
val selectionArgs = arrayOf("$folderPath/%", "$folderPath/%/%")
val imagePaths = mutableListOf<String>()
return contentResolver.query( contentResolver.query(
uri, mediaUri, arrayOf(MediaStore.MediaColumns.DATA), selection,
arrayOf(MediaStore.Images.Media._ID), selectionArgs, null
selection, )?.use { cursor ->
selectionArgs, val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
null)?.use { cursor -> while (cursor.moveToNext()) {
cursor.count val imagePath = cursor.getString(dataColumn)
} ?: 0 imagePaths.add(imagePath)
}
}
return imagePaths
} }
@ -180,14 +173,20 @@ object FolderDeletionHelper {
/** /**
* Deletes a specified folder and all of its contents on devices running * Deletes a list of image files specified by their paths, on
* Android 10 (API level 29) and below. * Android 10 (API level 29) and below.
* *
* @param folder The `File` object representing the folder to be deleted. * @param imagePaths A list of absolute file paths to image files that need to be deleted.
* @return `true` if the folder and all contents were deleted successfully; `false` otherwise. * @return `true` if all the images are successfully deleted, `false` otherwise.
*/ */
private fun deleteFolderLegacy(folder: File): Boolean { private fun deleteImagesLegacy(imagePaths: List<String>): Boolean {
return folder.deleteRecursively() var result = true
imagePaths.forEach {
val imageFile = File(it)
val deleted = imageFile.exists() && imageFile.delete()
result = result && deleted
}
return result
} }