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
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)
}
}

View file

@ -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),

View file

@ -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;
}
/**

View file

@ -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 };

View file

@ -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"

View file

@ -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" />

View file

@ -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>

View file

@ -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>