mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +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) | ||||
|         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 | ||||
|      */ | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 why-lab
						why-lab