mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-26 20:33:53 +01:00
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:
parent
b2810bcef1
commit
8a55b5e613
1 changed files with 73 additions and 74 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue