Issue 5811: folder deletion

This commit is contained in:
why-lab 2024-11-08 12:54:44 +01:00
parent d03e7bcf70
commit 764028d624
8 changed files with 233 additions and 275 deletions

View file

@ -1,59 +1,136 @@
package fr.free.nrw.commons.customselector.helper package fr.free.nrw.commons.customselector.helper
import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.RecoverableSecurityException
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.IntentSender
import android.content.pm.PackageManager
import android.media.MediaScannerConnection import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.MediaStore import android.provider.MediaStore
import android.widget.Toast
import androidx.appcompat.app.AlertDialog 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 timber.log.Timber
import java.io.File import java.io.File
object FolderDeletionHelper { 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) { fun confirmAndDeleteFolder(context: Context, folder: File, onDeletionComplete: (Boolean) -> Unit) {
val itemCount = countItemsInFolder(context, folder) val itemCount = countItemsInFolder(context, folder)
val folderPath = folder.absolutePath val folderPath = folder.absolutePath
// Show confirmation dialog //don't show this dialog on API 30+, it's handled automatically using MediaStore
AlertDialog.Builder(context) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
.setTitle("Confirm Deletion") val success = deleteFolderMain(context, folder)
.setMessage("Are you sure you want to delete the folder?\n\nPath: $folderPath\nItems: $itemCount") onDeletionComplete(success)
.setPositiveButton("Delete") { _, _ ->
// Proceed with deletion if user confirms } else {
val success = deleteFolder(context, folder) AlertDialog.Builder(context)
onDeletionComplete(success) .setTitle(context.getString(R.string.custom_selector_confirm_deletion_title))
} .setMessage(context.getString(R.string.custom_selector_confirm_deletion_message, folderPath, itemCount))
.setNegativeButton("Cancel") { dialog, _ -> .setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ ->
dialog.dismiss()
onDeletionComplete(false) // Return false if the user cancels //proceed with deletion if user confirms
} val success = deleteFolderMain(context, folder)
.show() 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 { return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> deleteFolderScopedStorage(context, folder) //for API 30 and above, use MediaStore
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q -> deleteFolderMediaStore(context, folder) 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) 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 { private fun countItemsInFolder(context: Context, folder: File): Int {
val contentResolver = context.contentResolver val contentResolver = context.contentResolver
@ -62,116 +139,52 @@ object FolderDeletionHelper {
val selection = "${MediaStore.Images.Media.DATA} LIKE ?" val selection = "${MediaStore.Images.Media.DATA} LIKE ?"
val selectionArgs = arrayOf("$folderPath/%") 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 cursor.count
} ?: 0 } ?: 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) { fun refreshMediaStore(context: Context, folder: File) {
MediaScannerConnection.scanFile( MediaScannerConnection.scanFile(
context, context,
arrayOf(folder.absolutePath), arrayOf(folder.absolutePath),
null 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 { private fun deleteFolderLegacy(folder: File): Boolean {
return folder.deleteRecursively().also { return folder.deleteRecursively()
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. * 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? { fun getFolderPath(context: Context, folderId: Long): String? {
val projection = arrayOf(MediaStore.Images.Media.DATA) val projection = arrayOf(MediaStore.Images.Media.DATA)
@ -190,36 +203,35 @@ object FolderDeletionHelper {
return File(fullPath).parent return File(fullPath).parent
} }
} }
Timber.tag("FolderDeletion").d("Path is null for folder ID: $folderId")
return null return null
} }
/**
fun printCurrentPermissions(context: Context) { * Displays an error message to the user and logs it for debugging purposes.
val permissions = mutableListOf( *
Manifest.permission.READ_EXTERNAL_STORAGE, * @param context The context used to display the Toast.
Manifest.permission.WRITE_EXTERNAL_STORAGE, * @param message The error message to display and log.
Manifest.permission.ACCESS_FINE_LOCATION, * @param folderName The name of the folder to delete.
Manifest.permission.ACCESS_COARSE_LOCATION */
) fun showError(context: Context, message: String, folderName: String) {
Toast.makeText(context,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { context.getString(R.string.custom_selector_folder_deleted_failure, folderName),
permissions.add(Manifest.permission.READ_MEDIA_IMAGES) Toast.LENGTH_SHORT).show()
permissions.add(Manifest.permission.ACCESS_MEDIA_LOCATION) Timber.tag("DeleteFolder").e(message)
} 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 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)
}
} }

View file

@ -6,19 +6,14 @@ import android.app.Dialog
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings
import android.view.View import android.view.View
import android.view.Window import android.view.Window
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.TextView
import android.widget.PopupMenu import android.widget.PopupMenu
import android.widget.Toast import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -43,19 +38,12 @@ import androidx.compose.ui.unit.dp
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProvider 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.R
import fr.free.nrw.commons.customselector.database.NotForUploadStatus import fr.free.nrw.commons.customselector.database.NotForUploadStatus
import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao import fr.free.nrw.commons.customselector.database.NotForUploadStatusDao
import fr.free.nrw.commons.customselector.helper.CustomSelectorConstants 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.CustomSelectorConstants.SHOULD_REFRESH
import fr.free.nrw.commons.customselector.helper.FolderDeletionHelper 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.FolderClickListener
import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener
import fr.free.nrw.commons.customselector.model.Image 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.theme.BaseActivity
import fr.free.nrw.commons.upload.FileUtilsWrapper import fr.free.nrw.commons.upload.FileUtilsWrapper
import fr.free.nrw.commons.utils.CustomSelectorUtils import fr.free.nrw.commons.utils.CustomSelectorUtils
import fr.free.nrw.commons.utils.PermissionUtils
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File import java.io.File
import java.lang.Integer.max import java.lang.Integer.max
import javax.inject.Inject import javax.inject.Inject
/** /**
* Custom Selector Activity. * Custom Selector Activity.
*/ */
@ -164,7 +151,7 @@ class CustomSelectorActivity :
private var showPartialAccessIndicator by mutableStateOf(false) private var showPartialAccessIndicator by mutableStateOf(false)
/** /**
* Show delete button on folder * Show delete button in folder
*/ */
private var showOverflowMenu = false private var showOverflowMenu = false
@ -266,24 +253,19 @@ class CustomSelectorActivity :
imageFragment?.passSelectedImages(selectedImages, shouldRefresh) imageFragment?.passSelectedImages(selectedImages, shouldRefresh)
} }
if (requestCode == 2) { // Consistent with handleRecoverableSecurityException if (requestCode == Constants.RequestCodes.DELETE_FOLDER_REQUEST_CODE &&
if (resultCode == Activity.RESULT_OK) { resultCode == Activity.RESULT_OK) {
Timber.tag("FolderAction").d("User confirmed deletion")
// Retry deletion for the pending files FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName)
val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) navigateToCustomSelector()
if (folderPath != null) {
val folder = File(folderPath)
FolderDeletionHelper.deleteFolder(this, folder)
navigateToMainScreen()
} else {
Timber.tag("FolderAction").e("User denied deletion request")
}
}
} }
} }
/** /**
* Show Custom Selector Welcome Dialog. * Show Custom Selector Welcome Dialog.
*/ */
@ -480,11 +462,10 @@ class CustomSelectorActivity :
val popupMenu = PopupMenu(this, anchorView) val popupMenu = PopupMenu(this, anchorView)
popupMenu.menuInflater.inflate(R.menu.menu_custom_selector, popupMenu.menu) popupMenu.menuInflater.inflate(R.menu.menu_custom_selector, popupMenu.menu)
// Handle menu item clicks
popupMenu.setOnMenuItemClickListener { item -> popupMenu.setOnMenuItemClickListener { item ->
when (item.itemId) { when (item.itemId) {
R.id.action_delete_folder -> { R.id.action_delete_folder -> {
deleteFolderWithPermissions() deleteFolder()
true true
} }
else -> false else -> false
@ -493,121 +474,63 @@ class CustomSelectorActivity :
popupMenu.show() 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. * Deletes folder based on Android API version.
*/ */
private fun deleteFolderByApiVersion() { private fun deleteFolder() {
val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) ?: run {
if (folderPath != null) { FolderDeletionHelper.showError(this, "Failed to retrieve folder path", bucketName)
val folder = File(folderPath) return
}
if (folder.exists() && folder.isDirectory) { val folder = File(folderPath)
FolderDeletionHelper.confirmAndDeleteFolder(this, folder) { success -> if (!folder.exists() || !folder.isDirectory) {
if (success) { FolderDeletionHelper.showError(this,"Folder not found or is not a directory", bucketName)
Toast.makeText(this, "Folder deleted", Toast.LENGTH_SHORT).show() return
Timber.tag("FolderAction").d("Folder deleted successfully") }
// Navigate back to main FolderFragment after deletion FolderDeletionHelper.confirmAndDeleteFolder(this, folder) { success ->
navigateToMainScreen() if (success) {
} else { // For API 30+, navigation is handled in 'onActivityResult'
Toast.makeText(this, "Failed to delete folder", Toast.LENGTH_SHORT).show() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
Timber.tag("FolderAction").e("Failed to delete folder") FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName)
} navigateToCustomSelector()
} }
} else { } else {
Toast.makeText(this, "Folder not found", Toast.LENGTH_SHORT).show() FolderDeletionHelper.showError(this, "Failed to delete folder", bucketName)
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")
} }
} }
/**
* 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) 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 //refresh MediaStore for the deleted folder path to ensure metadata updates
refreshMediaStore(this, folder) FolderDeletionHelper.refreshMediaStore(this, folder)
// Replace the current fragment with FolderFragment to go back to the main screen
//replace the current fragment with FolderFragment to go back to the main screen
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, FolderFragment.newInstance()) .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 isImageFragmentOpen = false
showOverflowMenu = false showOverflowMenu = false
setUpToolbar() setUpToolbar()
changeTitle(getString(R.string.custom_selector_title), 0) changeTitle(getString(R.string.custom_selector_title), 0)
// Fetch updated folder data //fetch updated folder data
fetchData() fetchData()
} }
@ -634,7 +557,7 @@ class CustomSelectorActivity :
bucketName = folderName bucketName = folderName
isImageFragmentOpen = true isImageFragmentOpen = true
// Show the overflow menu only when a folder is clicked //show the overflow menu only when a folder is clicked
showOverflowMenu = true showOverflowMenu = true
setUpToolbar() setUpToolbar()
@ -821,7 +744,9 @@ fun partialStorageAccessIndicator(
border = BorderStroke(0.5.dp, color = colorResource(R.color.primaryColor)), border = BorderStroke(0.5.dp, color = colorResource(R.color.primaryColor)),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) { ) {
Row(modifier = Modifier.padding(16.dp).fillMaxWidth()) { Row(modifier = Modifier
.padding(16.dp)
.fillMaxWidth()) {
Text( Text(
text = "You've given access to a select number of photos", text = "You've given access to a select number of photos",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),

View file

@ -19,6 +19,8 @@ public interface Constants {
int CAPTURE_VIDEO = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 14); int CAPTURE_VIDEO = FILE_PICKER_IMAGE_IDENTIFICATOR + (1 << 14);
int RECEIVE_DATA_FROM_FULL_SCREEN_MODE = 1 << 9; int RECEIVE_DATA_FROM_FULL_SCREEN_MODE = 1 << 9;
int DELETE_FOLDER_REQUEST_CODE = 1 << 16;
} }
/** /**

View file

@ -34,10 +34,15 @@ public class PermissionUtils {
return new String[]{ Manifest.permission.READ_MEDIA_IMAGES, return new String[]{ Manifest.permission.READ_MEDIA_IMAGES,
Manifest. permission.ACCESS_MEDIA_LOCATION }; 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, return new String[]{ Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.ACCESS_MEDIA_LOCATION }; 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[]{ return new String[]{
Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE }; Manifest.permission.WRITE_EXTERNAL_STORAGE };

View file

@ -11,7 +11,7 @@
android:id="@+id/partial_access_indicator" android:id="@+id/partial_access_indicator"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/toolbar_layout"/> app:layout_constraintTop_toBottomOf="@id/toolbar_layout" />
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container" android:id="@+id/fragment_container"

View file

@ -40,30 +40,34 @@
app:layout_constraintStart_toEndOf="@+id/back" app:layout_constraintStart_toEndOf="@+id/back"
app:layout_constraintTop_toTopOf="parent" /> app:layout_constraintTop_toTopOf="parent" />
<!-- Warning Icon (image_limit_error) -->
<ImageButton <ImageButton
android:id="@+id/image_limit_error" android:id="@+id/image_limit_error"
android:layout_width="48dp" android:layout_width="wrap_content"
android:layout_height="0dp" android:layout_height="match_parent"
android:background="#00FFFFFF" android:background="#00FFFFFF"
android:padding="@dimen/standard_gap"
android:contentDescription="@string/custom_selector_limit_error_desc" android:contentDescription="@string/custom_selector_limit_error_desc"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toStartOf="@id/menu_overflow"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1.0" app:layout_constraintVertical_bias="1.0"
app:layout_constraintStart_toEndOf="@id/title"
app:srcCompat="@drawable/ic_error_red_24dp" /> app:srcCompat="@drawable/ic_error_red_24dp" />
<!-- Overflow Menu Icon (menu_overflow) -->
<ImageButton <ImageButton
android:id="@+id/menu_overflow" android:id="@+id/menu_overflow"
android:layout_width="48dp" android:layout_width="wrap_content"
android:layout_height="0dp" android:layout_height="match_parent"
android:background="#00FFFFFF" android:background="#00FFFFFF"
android:padding="@dimen/standard_gap"
android:contentDescription="@string/menu_overflow_desc" android:contentDescription="@string/menu_overflow_desc"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="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_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1.0" app:layout_constraintVertical_bias="1.0"
app:layout_constraintStart_toEndOf="@id/image_limit_error"
app:srcCompat="@drawable/ic_overflow" /> app:srcCompat="@drawable/ic_overflow" />

View file

@ -3,7 +3,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item <item
android:id="@+id/action_delete_folder" 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" android:icon="@drawable/ic_delete_grey_700_24dp"
app:showAsAction="never" /> app:showAsAction="never" />
</menu> </menu>

View file

@ -832,5 +832,15 @@ Upload your first media by tapping on the add button.</string>
<string name="pending">Pending</string> <string name="pending">Pending</string>
<string name="failed">Failed</string> <string name="failed">Failed</string>
<string name="could_not_load_place_data">Could not load place data</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> </resources>