mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-11-02 15:53:55 +01:00
Issue 5811: folder deletion for api < 29.
This commit is contained in:
parent
1725304cce
commit
ec9e3d1b36
2 changed files with 349 additions and 30 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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)
|
||||
if(defaultKvStore.getBoolean("displayDeletionButton")) {
|
||||
overflowMenu.visibility = if (showOverflowMenu) View.VISIBLE else View.INVISIBLE
|
||||
|
||||
// Set up popup menu when overflow menu is clicked
|
||||
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,35 +486,119 @@ 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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue