From ec9e3d1b368ae3a9ba1b4f36f9b150d8e8a01198 Mon Sep 17 00:00:00 2001 From: why-lab Date: Sat, 2 Nov 2024 18:08:09 +0100 Subject: [PATCH] Issue 5811: folder deletion for api < 29. --- .../helper/FolderDeletionHelper.kt | 212 ++++++++++++++++++ .../ui/selector/CustomSelectorActivity.kt | 167 +++++++++++--- 2 files changed, 349 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt new file mode 100644 index 000000000..6f192107a --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt @@ -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") + } + } +} + + diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 821e30f6f..4789e42e8 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -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, + 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 */