mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +01:00 
			
		
		
		
	Issue 5811: folder deletion
This commit is contained in:
		
							parent
							
								
									d03e7bcf70
								
							
						
					
					
						commit
						764028d624
					
				
					 8 changed files with 233 additions and 275 deletions
				
			
		|  | @ -1,59 +1,136 @@ | |||
| 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.media.MediaScannerConnection | ||||
| import android.net.Uri | ||||
| import android.os.Build | ||||
| import android.provider.MediaStore | ||||
| import android.widget.Toast | ||||
| import androidx.appcompat.app.AlertDialog | ||||
| import androidx.core.content.ContextCompat | ||||
| import fr.free.nrw.commons.R | ||||
| import fr.free.nrw.commons.filepicker.Constants | ||||
| import timber.log.Timber | ||||
| import java.io.File | ||||
| 
 | ||||
| object FolderDeletionHelper { | ||||
| 
 | ||||
|     /** | ||||
|      * Main function to confirm and delete a folder. | ||||
|      * Prompts the user to confirm deletion of a specified folder and, if confirmed, deletes it. | ||||
|      * | ||||
|      * @param context The context used to show the confirmation dialog and manage deletion. | ||||
|      * @param folder The folder to be deleted. | ||||
|      * @param onDeletionComplete Callback invoked with `true` if the folder was | ||||
|      * successfully deleted, `false` otherwise. | ||||
|      */ | ||||
|     fun confirmAndDeleteFolder(context: Context, folder: File, onDeletionComplete: (Boolean) -> Unit) { | ||||
|         val itemCount = countItemsInFolder(context, 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() | ||||
|         //don't show this dialog on API 30+, it's handled automatically using MediaStore | ||||
|         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { | ||||
|             val success = deleteFolderMain(context, folder) | ||||
|             onDeletionComplete(success) | ||||
| 
 | ||||
|         } else { | ||||
|             AlertDialog.Builder(context) | ||||
|                 .setTitle(context.getString(R.string.custom_selector_confirm_deletion_title)) | ||||
|                 .setMessage(context.getString(R.string.custom_selector_confirm_deletion_message, folderPath, itemCount)) | ||||
|                 .setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ -> | ||||
| 
 | ||||
|                     //proceed with deletion if user confirms | ||||
|                     val success = deleteFolderMain(context, folder) | ||||
|                     onDeletionComplete(success) | ||||
|                 } | ||||
|                 .setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ -> | ||||
|                     dialog.dismiss() | ||||
|                     onDeletionComplete(false) | ||||
|                 } | ||||
|                 .show() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Delete the folder based on the Android version. | ||||
|      * Deletes the specified folder, handling different Android storage models based on the API | ||||
|      * | ||||
|      * @param context The context used to manage storage operations. | ||||
|      * @param folder The folder to delete. | ||||
|      * @return `true` if the folder deletion was successful, `false` otherwise. | ||||
|      */ | ||||
|    fun deleteFolder(context: Context, folder: File): Boolean { | ||||
|     private fun deleteFolderMain(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) | ||||
|             //for API 30 and above, use MediaStore | ||||
|             Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> trashFolderContents(context, folder) | ||||
| 
 | ||||
|             //for API 29 ('requestLegacyExternalStorage' is set to true in Manifest) | ||||
|             // and below use file system | ||||
|             else -> deleteFolderLegacy(folder) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Count the number of items in the folder, including subfolders. | ||||
|      * 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 folder The folder whose contents are to be moved to the trash. | ||||
|      * @return `true` if the trash request was initiated successfully, `false` otherwise. | ||||
|      */ | ||||
|     private fun trashFolderContents(context: Context, folder: File): Boolean { | ||||
|         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false | ||||
| 
 | ||||
|         val contentResolver = context.contentResolver | ||||
|         val folderPath = folder.absolutePath | ||||
|         val urisToTrash = mutableListOf<Uri>() | ||||
| 
 | ||||
|         // Use URIs specific to media items | ||||
|         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) { | ||||
|             val selection = "${MediaStore.MediaColumns.DATA} LIKE ?" | ||||
|             val selectionArgs = arrayOf("$folderPath/%") | ||||
| 
 | ||||
|             contentResolver.query(mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection, | ||||
|                 selectionArgs, null) | ||||
|                 ?.use{ cursor -> | ||||
|                 val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) | ||||
|                 while (cursor.moveToNext()) { | ||||
|                     val id = cursor.getLong(idColumn) | ||||
|                     val fileUri = ContentUris.withAppendedId(mediaUri, id) | ||||
|                     urisToTrash.add(fileUri) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         //proceed with trashing if we have valid URIs | ||||
|         if (urisToTrash.isNotEmpty()) { | ||||
|             try { | ||||
|                 val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true) | ||||
|                 (context as? Activity)?.startIntentSenderForResult( | ||||
|                     trashRequest.intentSender, | ||||
|                     Constants.RequestCodes.DELETE_FOLDER_REQUEST_CODE, | ||||
|                     null, 0, 0, 0 | ||||
|                 ) | ||||
|                 return true | ||||
|             } catch (e: SecurityException) { | ||||
|                 Timber.tag("DeleteFolder").e(context.getString(R.string.custom_selector_error_trashing_folder_contents, e.message)) | ||||
|             } | ||||
|         } | ||||
|         return false | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Counts the number of items in a specified folder, including items in subfolders. | ||||
|      * | ||||
|      * @param context The context used to access the content resolver. | ||||
|      * @param folder The folder in which to count items. | ||||
|      * @return The total number of items in the folder. | ||||
|      */ | ||||
|     private fun countItemsInFolder(context: Context, folder: File): Int { | ||||
|         val contentResolver = context.contentResolver | ||||
|  | @ -62,116 +139,52 @@ object FolderDeletionHelper { | |||
|         val selection = "${MediaStore.Images.Media.DATA} LIKE ?" | ||||
|         val selectionArgs = arrayOf("$folderPath/%") | ||||
| 
 | ||||
|         return contentResolver.query(uri, arrayOf(MediaStore.Images.Media._ID), selection, selectionArgs, null)?.use { cursor -> | ||||
|         return contentResolver.query( | ||||
|             uri, | ||||
|             arrayOf(MediaStore.Images.Media._ID), | ||||
|             selection, | ||||
|             selectionArgs, | ||||
|             null)?.use { cursor -> | ||||
|             cursor.count | ||||
|         } ?: 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). | ||||
|      * Refreshes the MediaStore for a specified folder, updating the system to recognize any changes | ||||
|      * | ||||
|      * @param context The context used to access the MediaScannerConnection. | ||||
|      * @param folder The folder to refresh in the MediaStore. | ||||
|      */ | ||||
|     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") | ||||
|         } | ||||
|     } | ||||
|     fun refreshMediaStore(context: Context, folder: File) { | ||||
|         MediaScannerConnection.scanFile( | ||||
|             context, | ||||
|             arrayOf(folder.absolutePath), | ||||
|             null | ||||
|         ) { path, uri -> | ||||
|             Timber.tag("FolderAction").d("MediaStore updated for path: $path, URI: $uri") | ||||
|         } | ||||
|         ) { _, _ -> } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Handles deletion for devices running Android 9 (API level 28) and below. | ||||
|      * Deletes a specified folder and all of its contents on devices running | ||||
|      * Android 10 (API level 29) and below. | ||||
|      * | ||||
|      * @param folder The `File` object representing the folder to be deleted. | ||||
|      * @return `true` if the folder and all contents were deleted successfully; `false` otherwise. | ||||
|      */ | ||||
|     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") | ||||
|             } | ||||
|         } | ||||
|         return folder.deleteRecursively() | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Retrieves the path of the folder with the specified ID from the MediaStore. | ||||
|      * Retrieves the absolute path of a folder given its unique identifier (bucket ID). | ||||
|      * | ||||
|      * @param context The context used to access the content resolver. | ||||
|      * @param folderId The unique identifier (bucket ID) of the folder. | ||||
|      * @return The absolute path of the folder as a `String`, or `null` if the folder is not found. | ||||
|      */ | ||||
|     fun getFolderPath(context: Context, folderId: Long): String? { | ||||
|         val projection = arrayOf(MediaStore.Images.Media.DATA) | ||||
|  | @ -190,36 +203,35 @@ object FolderDeletionHelper { | |||
|                 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") | ||||
|         } | ||||
|     /** | ||||
|      * Displays an error message to the user and logs it for debugging purposes. | ||||
|      * | ||||
|      * @param context The context used to display the Toast. | ||||
|      * @param message The error message to display and log. | ||||
|      * @param folderName The name of the folder to delete. | ||||
|      */ | ||||
|     fun showError(context: Context, message: String, folderName: String) { | ||||
|         Toast.makeText(context, | ||||
|             context.getString(R.string.custom_selector_folder_deleted_failure, folderName), | ||||
|             Toast.LENGTH_SHORT).show() | ||||
|         Timber.tag("DeleteFolder").e(message) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Displays a success message to the user. | ||||
|      * | ||||
|      * @param context The context used to display the Toast. | ||||
|      * @param message The success message to display. | ||||
|      * @param folderName The name of the folder to delete. | ||||
|      */ | ||||
|     fun showSuccess(context: Context, message: String, folderName: String) { | ||||
|         Toast.makeText(context, | ||||
|             context.getString(R.string.custom_selector_folder_deleted_success, folderName), | ||||
|             Toast.LENGTH_SHORT).show() | ||||
|         Timber.tag("DeleteFolder").d(message) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -6,19 +6,14 @@ import android.app.Dialog | |||
| 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 | ||||
| import android.widget.ImageButton | ||||
| import android.widget.TextView | ||||
| import android.widget.PopupMenu | ||||
| import android.widget.Toast | ||||
| import androidx.appcompat.app.AlertDialog | ||||
| 
 | ||||
| import android.widget.TextView | ||||
| import androidx.compose.foundation.BorderStroke | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
|  | @ -43,19 +38,12 @@ 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.helper.FolderDeletionHelper.getFolderPath | ||||
| import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper.refreshMediaStore | ||||
| import fr.free.nrw.commons.customselector.listeners.FolderClickListener | ||||
| import fr.free.nrw.commons.customselector.listeners.ImageSelectListener | ||||
| import fr.free.nrw.commons.customselector.model.Image | ||||
|  | @ -67,18 +55,17 @@ 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 | ||||
| import kotlinx.coroutines.MainScope | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.withContext | ||||
| import timber.log.Timber | ||||
| import java.io.File | ||||
| import java.lang.Integer.max | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Custom Selector Activity. | ||||
|  */ | ||||
|  | @ -164,7 +151,7 @@ class CustomSelectorActivity : | |||
|     private var showPartialAccessIndicator by mutableStateOf(false) | ||||
| 
 | ||||
|     /** | ||||
|      * Show delete button on folder | ||||
|      * Show delete button in folder | ||||
|      */ | ||||
|     private var showOverflowMenu = false | ||||
| 
 | ||||
|  | @ -266,24 +253,19 @@ class CustomSelectorActivity : | |||
|             imageFragment?.passSelectedImages(selectedImages, shouldRefresh) | ||||
|         } | ||||
| 
 | ||||
|         if (requestCode == 2) { // Consistent with handleRecoverableSecurityException | ||||
|             if (resultCode == Activity.RESULT_OK) { | ||||
|                 Timber.tag("FolderAction").d("User confirmed deletion") | ||||
|                 // Retry deletion for the pending files | ||||
|                 val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) | ||||
|                 if (folderPath != null) { | ||||
|                     val folder = File(folderPath) | ||||
|                     FolderDeletionHelper.deleteFolder(this, folder) | ||||
|                     navigateToMainScreen() | ||||
|                 } else { | ||||
|                     Timber.tag("FolderAction").e("User denied deletion request") | ||||
|                 } | ||||
|             } | ||||
|         if (requestCode == Constants.RequestCodes.DELETE_FOLDER_REQUEST_CODE && | ||||
|             resultCode == Activity.RESULT_OK) { | ||||
| 
 | ||||
|             FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName) | ||||
|             navigateToCustomSelector() | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|     /** | ||||
|      * Show Custom Selector Welcome Dialog. | ||||
|      */ | ||||
|  | @ -480,11 +462,10 @@ class CustomSelectorActivity : | |||
|         val popupMenu = PopupMenu(this, anchorView) | ||||
|         popupMenu.menuInflater.inflate(R.menu.menu_custom_selector, popupMenu.menu) | ||||
| 
 | ||||
|         // Handle menu item clicks | ||||
|         popupMenu.setOnMenuItemClickListener { item -> | ||||
|             when (item.itemId) { | ||||
|                 R.id.action_delete_folder -> { | ||||
|                     deleteFolderWithPermissions() | ||||
|                     deleteFolder() | ||||
|                     true | ||||
|                 } | ||||
|                 else -> false | ||||
|  | @ -493,121 +474,63 @@ class CustomSelectorActivity : | |||
|         popupMenu.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) | ||||
|     private fun deleteFolder() { | ||||
|         val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) ?: run { | ||||
|             FolderDeletionHelper.showError(this, "Failed to retrieve folder path", bucketName) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|             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") | ||||
|         val folder = File(folderPath) | ||||
|         if (!folder.exists() || !folder.isDirectory) { | ||||
|             FolderDeletionHelper.showError(this,"Folder not found or is not a directory", bucketName) | ||||
|             return | ||||
|         } | ||||
| 
 | ||||
|                         // Navigate back to main FolderFragment after deletion | ||||
|                         navigateToMainScreen() | ||||
|                     } else { | ||||
|                         Toast.makeText(this, "Failed to delete folder", Toast.LENGTH_SHORT).show() | ||||
|                         Timber.tag("FolderAction").e("Failed to delete folder") | ||||
|                     } | ||||
|         FolderDeletionHelper.confirmAndDeleteFolder(this, folder) { success -> | ||||
|             if (success) { | ||||
|                 // For API 30+, navigation is handled in 'onActivityResult' | ||||
|                 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { | ||||
|                     FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName) | ||||
|                     navigateToCustomSelector() | ||||
|                 } | ||||
|             } else { | ||||
|                 Toast.makeText(this, "Folder not found", Toast.LENGTH_SHORT).show() | ||||
|                 Timber.tag("FolderAction").e("Folder not found") | ||||
|                 FolderDeletionHelper.showError(this, "Failed to delete folder", bucketName) | ||||
|             } | ||||
|         } 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") | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Navigate back to the main FolderFragment. | ||||
|      */ | ||||
|     private fun navigateToMainScreen() { | ||||
| 
 | ||||
|         val folderPath = getFolderPath(this, bucketId) ?: "" | ||||
| 
 | ||||
|     /** | ||||
|      * Navigates back to the main `FolderFragment`, refreshes the MediaStore, resets UI states, | ||||
|      * and reloads folder data. | ||||
|      */ | ||||
|     private fun navigateToCustomSelector() { | ||||
| 
 | ||||
|         val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) ?: "" | ||||
|         val folder = File(folderPath) | ||||
| 
 | ||||
|         supportFragmentManager.popBackStack(null, androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE) | ||||
|         supportFragmentManager.popBackStack(null, | ||||
|                                 androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE) | ||||
| 
 | ||||
|         // Refresh MediaStore for the deleted folder path to ensure metadata updates | ||||
|         refreshMediaStore(this, folder) | ||||
|         // Replace the current fragment with FolderFragment to go back to the main screen | ||||
|         //refresh MediaStore for the deleted folder path to ensure metadata updates | ||||
|         FolderDeletionHelper.refreshMediaStore(this, folder) | ||||
| 
 | ||||
|         //replace the current fragment with FolderFragment to go back to the main screen | ||||
|         supportFragmentManager.beginTransaction() | ||||
|             .replace(R.id.fragment_container, FolderFragment.newInstance()) | ||||
|             .commitAllowingStateLoss() // Ensure this transaction executes, even if the activity state is not fully stable | ||||
|             .commitAllowingStateLoss() | ||||
| 
 | ||||
|         // Reset toolbar and flags | ||||
|         //reset toolbar and flags | ||||
|         isImageFragmentOpen = false | ||||
|         showOverflowMenu = false | ||||
|         setUpToolbar() | ||||
|         changeTitle(getString(R.string.custom_selector_title), 0) | ||||
| 
 | ||||
|         // Fetch updated folder data | ||||
|         //fetch updated folder data | ||||
|         fetchData() | ||||
|     } | ||||
| 
 | ||||
|  | @ -634,7 +557,7 @@ class CustomSelectorActivity : | |||
|         bucketName = folderName | ||||
|         isImageFragmentOpen = true | ||||
| 
 | ||||
|         // Show the overflow menu only when a folder is clicked | ||||
|         //show the overflow menu only when a folder is clicked | ||||
|         showOverflowMenu = true | ||||
|         setUpToolbar() | ||||
| 
 | ||||
|  | @ -821,7 +744,9 @@ fun partialStorageAccessIndicator( | |||
|             border = BorderStroke(0.5.dp, color = colorResource(R.color.primaryColor)), | ||||
|             shape = RoundedCornerShape(8.dp), | ||||
|         ) { | ||||
|             Row(modifier = Modifier.padding(16.dp).fillMaxWidth()) { | ||||
|             Row(modifier = Modifier | ||||
|                 .padding(16.dp) | ||||
|                 .fillMaxWidth()) { | ||||
|                 Text( | ||||
|                     text = "You've given access to a select number of photos", | ||||
|                     modifier = Modifier.weight(1f), | ||||
|  |  | |||
|  | @ -19,6 +19,8 @@ public interface Constants { | |||
|         int CAPTURE_VIDEO = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 14); | ||||
| 
 | ||||
|         int RECEIVE_DATA_FROM_FULL_SCREEN_MODE = 1 << 9; | ||||
| 
 | ||||
|         int DELETE_FOLDER_REQUEST_CODE = 1 << 16; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -34,10 +34,15 @@ public class PermissionUtils { | |||
|             return new String[]{ Manifest.permission.READ_MEDIA_IMAGES, | ||||
|             Manifest. permission.ACCESS_MEDIA_LOCATION }; | ||||
|         } | ||||
|         if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { | ||||
|         if(Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { | ||||
|             return new String[]{ Manifest.permission.READ_EXTERNAL_STORAGE, | ||||
|             Manifest.permission.ACCESS_MEDIA_LOCATION }; | ||||
|         } | ||||
|         if(Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { | ||||
|             return new String[]{ Manifest.permission.READ_EXTERNAL_STORAGE, | ||||
|                 Manifest.permission.WRITE_EXTERNAL_STORAGE, | ||||
|                 Manifest.permission.ACCESS_MEDIA_LOCATION }; | ||||
|         } | ||||
|         return new String[]{ | ||||
|             Manifest.permission.READ_EXTERNAL_STORAGE, | ||||
|             Manifest.permission.WRITE_EXTERNAL_STORAGE }; | ||||
|  |  | |||
|  | @ -11,7 +11,7 @@ | |||
|     android:id="@+id/partial_access_indicator" | ||||
|     android:layout_width="match_parent" | ||||
|     android:layout_height="0dp" | ||||
|     app:layout_constraintTop_toBottomOf="@id/toolbar_layout"/> | ||||
|     app:layout_constraintTop_toBottomOf="@id/toolbar_layout" /> | ||||
| 
 | ||||
|   <androidx.fragment.app.FragmentContainerView | ||||
|     android:id="@+id/fragment_container" | ||||
|  |  | |||
|  | @ -40,30 +40,34 @@ | |||
|       app:layout_constraintStart_toEndOf="@+id/back" | ||||
|       app:layout_constraintTop_toTopOf="parent" /> | ||||
| 
 | ||||
|     <!-- Warning Icon (image_limit_error) --> | ||||
|     <ImageButton | ||||
|       android:id="@+id/image_limit_error" | ||||
|       android:layout_width="48dp" | ||||
|       android:layout_height="0dp" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="match_parent" | ||||
|       android:background="#00FFFFFF" | ||||
|       android:padding="@dimen/standard_gap" | ||||
|       android:contentDescription="@string/custom_selector_limit_error_desc" | ||||
|       app:layout_constraintBottom_toBottomOf="parent" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintEnd_toStartOf="@id/menu_overflow" | ||||
|       app:layout_constraintTop_toTopOf="parent" | ||||
|       app:layout_constraintVertical_bias="1.0" | ||||
|       app:layout_constraintStart_toEndOf="@id/title" | ||||
|       app:srcCompat="@drawable/ic_error_red_24dp" /> | ||||
| 
 | ||||
|     <!-- Overflow Menu Icon (menu_overflow) --> | ||||
|     <ImageButton | ||||
|       android:id="@+id/menu_overflow" | ||||
|       android:layout_width="48dp" | ||||
|       android:layout_height="0dp" | ||||
|       android:layout_width="wrap_content" | ||||
|       android:layout_height="match_parent" | ||||
|       android:background="#00FFFFFF" | ||||
|       android:padding="@dimen/standard_gap" | ||||
|       android:contentDescription="@string/menu_overflow_desc" | ||||
|       app:layout_constraintBottom_toBottomOf="parent" | ||||
|       app:layout_constraintEnd_toEndOf="parent" | ||||
|       app:layout_constraintHorizontal_bias="0.0" | ||||
|       app:layout_constraintStart_toEndOf="@id/title" | ||||
|       app:layout_constraintTop_toTopOf="parent" | ||||
|       app:layout_constraintVertical_bias="1.0" | ||||
|       app:layout_constraintStart_toEndOf="@id/image_limit_error" | ||||
|       app:srcCompat="@drawable/ic_overflow" /> | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
|       xmlns:app="http://schemas.android.com/apk/res-auto"> | ||||
|   <item | ||||
|     android:id="@+id/action_delete_folder" | ||||
|     android:title="@string/delete_folder" | ||||
|     android:title="@string/custom_selector_delete_folder" | ||||
|     android:icon="@drawable/ic_delete_grey_700_24dp" | ||||
|     app:showAsAction="never" /> | ||||
| </menu> | ||||
|  | @ -832,5 +832,15 @@ Upload your first media by tapping on the add button.</string> | |||
|   <string name="pending">Pending</string> | ||||
|   <string name="failed">Failed</string> | ||||
|   <string name="could_not_load_place_data">Could not load place data</string> | ||||
|   <string name="delete_folder">Delete Folder</string> | ||||
| 
 | ||||
|   <string name="custom_selector_delete_folder">Delete Folder</string> | ||||
|   <string name="custom_selector_confirm_deletion_title">Confirm Deletion</string> | ||||
|   <string name="custom_selector_confirm_deletion_message">Are you sure you want to delete folder %1$s containing %2$d items?</string> | ||||
|   <string name="custom_selector_delete">Delete</string> | ||||
|   <string name="custom_selector_cancel">Cancel</string> | ||||
|   <string name="custom_selector_folder_deleted_success">Folder %1$s deleted successfully</string> | ||||
|   <string name="custom_selector_folder_deleted_failure">Failed to delete folder %1$s</string> | ||||
|   <string name="custom_selector_error_trashing_folder_contents">Error in trashing folder contents: %1$s</string> | ||||
|   <string name="custom_selector_folder_not_found_error">Failed to retrieve folder path for bucket ID: %1$d</string> | ||||
| 
 | ||||
| </resources> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 why-lab
						why-lab