Issue 5811: folder deletion for api < 29.

This commit is contained in:
why-lab 2024-11-02 18:08:09 +01:00
parent 1725304cce
commit ec9e3d1b36
2 changed files with 349 additions and 30 deletions

View file

@ -0,0 +1,212 @@
package fr.free.nrw.commons.customselector.helper
import android.Manifest
import android.app.Activity
import android.app.RecoverableSecurityException
import android.content.ContentUris
import android.content.Context
import android.content.IntentSender
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import fr.free.nrw.commons.R
import fr.free.nrw.commons.utils.PermissionUtils
import timber.log.Timber
import java.io.File
object FolderDeletionHelper {
/**
* Main function to confirm and delete a folder.
*/
fun confirmAndDeleteFolder(context: Context, folder: File, onDeletionComplete: (Boolean) -> Unit) {
val itemCount = countItemsInFolder(folder)
val folderPath = folder.absolutePath
// Show confirmation dialog
AlertDialog.Builder(context)
.setTitle("Confirm Deletion")
.setMessage("Are you sure you want to delete the folder?\n\nPath: $folderPath\nItems: $itemCount")
.setPositiveButton("Delete") { _, _ ->
// Proceed with deletion if user confirms
val success = deleteFolder(context, folder)
onDeletionComplete(success)
}
.setNegativeButton("Cancel") { dialog, _ ->
dialog.dismiss()
onDeletionComplete(false) // Return false if the user cancels
}
.show()
}
/**
* Delete the folder based on the Android version.
*/
private fun deleteFolder(context: Context, folder: File): Boolean {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> deleteFolderScopedStorage(context, folder)
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q -> deleteFolderMediaStore(context, folder)
else -> deleteFolderLegacy(folder)
}
}
/**
* Count the number of items in the folder, including subfolders.
*/
private fun countItemsInFolder(folder: File): Int {
return folder.listFiles()?.size ?: 0
}
/**
* Deletes a folder using the Scoped Storage API for Android 11 (API level 30) and above.
*/
private fun deleteFolderScopedStorage(context: Context, folder: File): Boolean {
Timber.tag("FolderAction").d("Deleting folder using Scoped Storage API")
// Implement deletion with Scoped Storage; fallback to recursive delete
return folder.deleteRecursively().also {
if (!it) {
Timber.tag("FolderAction").e("Failed to delete folder with Scoped Storage API")
}
}
}
/**
* Deletes a folder using the MediaStore API for Android 10 (API level 29).
*/
/**
* Deletes a folder using the MediaStore API for Android 10 (API level 29).
*/
fun deleteFolderMediaStore(context: Context, folder: File): Boolean {
Timber.tag("FolderAction").d("Deleting folder using MediaStore API on Android 10")
val contentResolver = context.contentResolver
val folderPath = folder.absolutePath
var deletionSuccessful = true
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val selection = "${MediaStore.Images.Media.DATA} LIKE ?"
val selectionArgs = arrayOf("$folderPath/%")
contentResolver.query(uri, arrayOf(MediaStore.Images.Media._ID), selection, selectionArgs, null)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val imageUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
try {
val rowsDeleted = contentResolver.delete(imageUri, null, null)
if (rowsDeleted <= 0) {
Timber.tag("FolderAction").e("Failed to delete image with URI: $imageUri")
deletionSuccessful = false
}
} catch (e: Exception) {
// Handle RecoverableSecurityException only for API 29 and above
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && e is RecoverableSecurityException) {
handleRecoverableSecurityException(context, e)
deletionSuccessful = false
} else {
Timber.tag("FolderAction").e("Error deleting file: ${e.message}")
deletionSuccessful = false
}
}
}
}
return deletionSuccessful
}
/**
* Handles the RecoverableSecurityException for deletion requests requiring user confirmation.
*/
private fun handleRecoverableSecurityException(context: Context, e: RecoverableSecurityException) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
try {
val intentSender = e.userAction.actionIntent.intentSender
(context as? Activity)?.startIntentSenderForResult(
intentSender,
2,
null, 0, 0, 0
)
} catch (ex: IntentSender.SendIntentException) {
Timber.tag("FolderAction").e("Error sending intent for deletion: ${ex.message}")
}
} else {
Timber.tag("FolderAction").e("RecoverableSecurityException requires API 29 or higher")
}
}
/**
* Handles deletion for devices running Android 9 (API level 28) and below.
*/
private fun deleteFolderLegacy(folder: File): Boolean {
return folder.deleteRecursively().also {
if (it) {
Timber.tag("FolderAction").d("Folder deleted successfully")
} else {
Timber.tag("FolderAction").e("Failed to delete folder")
}
}
}
/**
* Retrieves the path of the folder with the specified ID from the MediaStore.
*/
fun getFolderPath(context: Context, folderId: Long): String? {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val selection = "${MediaStore.Images.Media.BUCKET_ID} = ?"
val selectionArgs = arrayOf(folderId.toString())
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val fullPath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
return File(fullPath).parent
}
}
Timber.tag("FolderDeletion").d("Path is null for folder ID: $folderId")
return null
}
fun printCurrentPermissions(context: Context) {
val permissions = mutableListOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.READ_MEDIA_IMAGES)
permissions.add(Manifest.permission.ACCESS_MEDIA_LOCATION)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE)
permissions.add(Manifest.permission.ACCESS_MEDIA_LOCATION)
} else {
permissions.add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
for (permission in permissions) {
val status = if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) {
"GRANTED"
} else {
"DENIED"
}
Timber.tag("PermissionsStatus").d("$permission: $status")
}
}
}

View file

@ -3,11 +3,14 @@ package fr.free.nrw.commons.customselector.ui.selector
import android.Manifest
import android.app.Activity
import android.app.Dialog
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.View
import android.view.Window
import android.widget.Button
@ -16,6 +19,7 @@ import android.widget.TextView
import android.view.Menu
import android.view.MenuItem
import android.widget.PopupMenu
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.BorderStroke
@ -42,11 +46,17 @@ import androidx.compose.ui.unit.dp
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider
import com.karumi.dexter.Dexter
import com.karumi.dexter.MultiplePermissionsReport
import com.karumi.dexter.PermissionToken
import com.karumi.dexter.listener.PermissionRequest
import com.karumi.dexter.listener.multi.MultiplePermissionsListener
import fr.free.nrw.commons.R
import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants.SHOULD_REFRESH
import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper
import fr.free.nrw.commons.customselector.listeners.FolderClickListener
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.model.Image
@ -58,6 +68,7 @@ import fr.free.nrw.commons.media.ZoomableActivity
import fr.free.nrw.commons.theme.BaseActivity
import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils
import fr.free.nrw.commons.utils.PermissionUtils
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -254,6 +265,16 @@ class CustomSelectorActivity :
val shouldRefresh = data.getBooleanExtra(SHOULD_REFRESH, false)
imageFragment?.passSelectedImages(selectedImages, shouldRefresh)
}
if (requestCode == 2) {
if (resultCode == Activity.RESULT_OK) {
Timber.tag("FolderAction").d("User confirmed deletion")
// Retry deletion or refresh UI if needed
} else {
Timber.tag("FolderAction").e("User denied deletion request")
}
}
}
/**
@ -439,10 +460,12 @@ class CustomSelectorActivity :
limitError.setOnClickListener { displayUploadLimitWarning() }
val overflowMenu: ImageButton = findViewById(R.id.menu_overflow)
overflowMenu.visibility = if (showOverflowMenu) View.VISIBLE else View.INVISIBLE
// Set up popup menu when overflow menu is clicked
overflowMenu.setOnClickListener { showPopupMenu(overflowMenu) }
if(defaultKvStore.getBoolean("displayDeletionButton")) {
overflowMenu.visibility = if (showOverflowMenu) View.VISIBLE else View.INVISIBLE
overflowMenu.setOnClickListener { showPopupMenu(overflowMenu) }
}else{
overflowMenu.visibility = View.GONE
}
}
@ -454,7 +477,7 @@ class CustomSelectorActivity :
popupMenu.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_delete_folder -> {
deleteFolder() // Call the delete folder logic here
deleteFolderWithPermissions()
true
}
else -> false
@ -463,37 +486,121 @@ class CustomSelectorActivity :
popupMenu.show()
}
private fun deleteFolder() {
Timber.tag("FolderAction").d("Delete folder action triggered")
// Run on UI thread to ensure dialog shows correctly
runOnUiThread {
val builder = AlertDialog.Builder(this)
builder.setTitle("Delete Folder")
builder.setMessage("Are you sure you want to delete this folder?")
// Set the positive button to confirm deletion
builder.setPositiveButton("Delete") { dialog, _ ->
// Perform folder deletion here
Timber.tag("FolderAction").d("Folder deleted")
dialog.dismiss()
}
// Set the negative button to cancel
builder.setNegativeButton("Cancel") { dialog, _ ->
Timber.tag("FolderAction").d("Delete action cancelled")
dialog.dismiss() // Dismiss the dialog
}
// Show the AlertDialog
builder.create().show()
/**
* Triggers folder deletion after permission checks.
*/
private fun deleteFolderWithPermissions() {
val permissions = PermissionUtils.PERMISSIONS_STORAGE
if (PermissionUtils.hasPermission(this, permissions)) {
deleteFolderByApiVersion()
} else {
requestPermissionsIfNeeded()
}
}
/**
* Checks and requests necessary permissions using Dexter library.
*/
private fun requestPermissionsIfNeeded() {
val permissions = PermissionUtils.PERMISSIONS_STORAGE
Dexter.withActivity(this)
.withPermissions(*permissions)
.withListener(object : MultiplePermissionsListener {
override fun onPermissionsChecked(report: MultiplePermissionsReport) {
if (report.areAllPermissionsGranted()) {
deleteFolderByApiVersion()
} else if (report.isAnyPermissionPermanentlyDenied) {
showSettingsDialog()
} else {
Toast.makeText(
this@CustomSelectorActivity,
"Permissions required to delete folder.",
Toast.LENGTH_SHORT
).show()
}
}
override fun onPermissionRationaleShouldBeShown(
permissions: List<PermissionRequest>,
token: PermissionToken
) {
token.continuePermissionRequest()
}
}).check()
}
/**
* Show a dialog directing the user to settings if permissions are permanently denied.
*/
private fun showSettingsDialog() {
AlertDialog.Builder(this)
.setTitle("Need Permissions")
.setMessage("This app needs storage permissions to delete folders. You can grant them in app settings.")
.setPositiveButton("Go to Settings") { _, _ ->
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.fromParts("package", packageName, null)
startActivity(intent)
}
.setNegativeButton("Cancel") { dialog, _ -> dialog.dismiss() }
.show()
}
/**
* Deletes folder based on Android API version.
*/
private fun deleteFolderByApiVersion() {
val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId)
if (folderPath != null) {
val folder = File(folderPath)
if (folder.exists() && folder.isDirectory) {
FolderDeletionHelper.confirmAndDeleteFolder(this, folder) { success ->
if (success) {
Toast.makeText(this, "Folder deleted", Toast.LENGTH_SHORT).show()
Timber.tag("FolderAction").d("Folder deleted successfully")
reloadFolderList()
} else {
Toast.makeText(this, "Failed to delete folder", Toast.LENGTH_SHORT).show()
Timber.tag("FolderAction").e("Failed to delete folder")
}
}
} else {
Toast.makeText(this, "Folder not found", Toast.LENGTH_SHORT).show()
Timber.tag("FolderAction").e("Folder not found")
}
} else {
Toast.makeText(this, "Folder path not found", Toast.LENGTH_SHORT).show()
Timber.tag("FolderDeletion").e("Failed to retrieve folder path for bucket ID: $bucketId")
}
}
private fun reloadFolderList() {
// Clear any saved state for the last open folder
prefs.edit()
.remove(FOLDER_ID)
.remove(FOLDER_NAME)
.apply()
supportFragmentManager.popBackStack(null, androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE)
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, FolderFragment.newInstance())
.commit()
// Reset the toolbar and other flags
isImageFragmentOpen = false
showOverflowMenu = false
fetchData()
setUpToolbar()
changeTitle(getString(R.string.custom_selector_title), 0)
}
/**
* override on folder click,
* change the toolbar title on folder click, make overflow menu visible
*/