diff --git a/app/build.gradle b/app/build.gradle index c949707c2..9e6c56c83 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -93,7 +93,6 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION" //Mocking - testImplementation("io.mockk:mockk:1.13.4") testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0' testImplementation 'org.mockito:mockito-inline:5.2.0' testImplementation 'org.mockito:mockito-core:5.6.0' @@ -367,11 +366,11 @@ android { compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } buildToolsVersion buildToolsVersion diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt new file mode 100644 index 000000000..0ead4c289 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/customselector/helper/FolderDeletionHelper.kt @@ -0,0 +1,249 @@ +package fr.free.nrw.commons.customselector.helper + +import android.content.ContentUris +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.appcompat.app.AlertDialog +import fr.free.nrw.commons.R +import timber.log.Timber +import java.io.File + +object FolderDeletionHelper { + + /** + * 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 + * @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request. + * successfully deleted, `false` otherwise. + */ + fun confirmAndDeleteFolder( + context: Context, + folder: File, + trashFolderLauncher: ActivityResultLauncher, + onDeletionComplete: (Boolean) -> Unit) { + val itemCount = countItemsInFolder(context, folder) + val folderPath = folder.absolutePath + + //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, trashFolderLauncher) + 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, trashFolderLauncher) + onDeletionComplete(success) + } + .setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ -> + dialog.dismiss() + onDeletionComplete(false) + } + .show() + } + } + + /** + * 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. + * @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request. + * @return `true` if the folder deletion was successful, `false` otherwise. + */ + private fun deleteFolderMain( + context: Context, + folder: File, + trashFolderLauncher: ActivityResultLauncher): Boolean + { + return when { + //for API 30 and above, use MediaStore + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> trashFolderContents(context, folder, trashFolderLauncher) + + //for API 29 ('requestLegacyExternalStorage' is set to true in Manifest) + // and below use file system + else -> deleteFolderLegacy(folder) + } + } + + /** + * 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. + * @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request. + * @return `true` if the trash request was initiated successfully, `false` otherwise. + */ + private fun trashFolderContents( + context: Context, + folder: File, + trashFolderLauncher: ActivityResultLauncher): Boolean + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false + + val contentResolver = context.contentResolver + val folderPath = folder.absolutePath + val urisToTrash = mutableListOf() + + // 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) + val intentSenderRequest = IntentSenderRequest.Builder(trashRequest.intentSender).build() + trashFolderLauncher.launch(intentSenderRequest) + 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 + val folderPath = folder.absolutePath + val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI + 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 -> + cursor.count + } ?: 0 + } + + + + /** + * 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 refreshMediaStore(context: Context, folder: File) { + MediaScannerConnection.scanFile( + context, + arrayOf(folder.absolutePath), + null + ) { _, _ -> } + } + + + + /** + * 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() + } + + + /** + * 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) + val selection = "${MediaStore.Images.Media.BUCKET_ID} = ?" + val selectionArgs = arrayOf(folderId.toString()) + + context.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + projection, + selection, + selectionArgs, + null + )?.use { cursor -> + if (cursor.moveToFirst()) { + val fullPath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)) + return File(fullPath).parent + } + } + return null + } + + /** + * 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) + } + +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt index 959db52f3..6c6d7e53f 100644 --- a/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt +++ b/app/src/main/java/fr/free/nrw/commons/customselector/ui/selector/CustomSelectorActivity.kt @@ -12,8 +12,10 @@ import android.view.View import android.view.Window import android.widget.Button import android.widget.ImageButton +import android.widget.PopupMenu import android.widget.TextView import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Row @@ -43,13 +45,13 @@ 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.FolderDeletionHelper import fr.free.nrw.commons.customselector.listeners.FolderClickListener import fr.free.nrw.commons.customselector.listeners.ImageSelectListener import fr.free.nrw.commons.customselector.model.Image import fr.free.nrw.commons.databinding.ActivityCustomSelectorBinding import fr.free.nrw.commons.databinding.CustomSelectorBottomLayoutBinding import fr.free.nrw.commons.databinding.CustomSelectorToolbarBinding -import fr.free.nrw.commons.filepicker.Constants import fr.free.nrw.commons.media.ZoomableActivity import fr.free.nrw.commons.theme.BaseActivity import fr.free.nrw.commons.upload.FileUtilsWrapper @@ -148,10 +150,23 @@ class CustomSelectorActivity : private var showPartialAccessIndicator by mutableStateOf(false) + /** + * Show delete button in folder + */ + private var showOverflowMenu = false + + /** + * Waits for confirmation of delete folder + */ + private val startForFolderDeletionResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()){ + result -> onDeleteFolderResultReceived(result) + } + private val startForResult = registerForActivityResult(StartActivityForResult()){ result -> onFullScreenDataReceived(result) } + /** * onCreate Activity, sets theme, initialises the view model, setup view. */ @@ -239,6 +254,15 @@ class CustomSelectorActivity : } } + private fun onDeleteFolderResultReceived(result: ActivityResult){ + if (result.resultCode == Activity.RESULT_OK){ + FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName) + navigateToCustomSelector() + } + } + + + /** * Show Custom Selector Welcome Dialog. */ @@ -420,10 +444,97 @@ class CustomSelectorActivity : val limitError: ImageButton = findViewById(R.id.image_limit_error) limitError.visibility = View.INVISIBLE limitError.setOnClickListener { displayUploadLimitWarning() } + + val overflowMenu: ImageButton = findViewById(R.id.menu_overflow) + if(defaultKvStore.getBoolean("displayDeletionButton")) { + overflowMenu.visibility = if (showOverflowMenu) View.VISIBLE else View.INVISIBLE + overflowMenu.setOnClickListener { showPopupMenu(overflowMenu) } + }else{ + overflowMenu.visibility = View.GONE + } + + } + + private fun showPopupMenu(anchorView: View) { + val popupMenu = PopupMenu(this, anchorView) + popupMenu.menuInflater.inflate(R.menu.menu_custom_selector, popupMenu.menu) + + popupMenu.setOnMenuItemClickListener { item -> + when (item.itemId) { + R.id.action_delete_folder -> { + deleteFolder() + true + } + else -> false + } + } + popupMenu.show() } /** - * override on folder click, change the toolbar title on folder click. + * Deletes folder based on Android API version. + */ + private fun deleteFolder() { + val folderPath = FolderDeletionHelper.getFolderPath(this, bucketId) ?: run { + FolderDeletionHelper.showError(this, "Failed to retrieve folder path", bucketName) + return + } + + val folder = File(folderPath) + if (!folder.exists() || !folder.isDirectory) { + FolderDeletionHelper.showError(this,"Folder not found or is not a directory", bucketName) + return + } + + FolderDeletionHelper.confirmAndDeleteFolder(this, folder, startForFolderDeletionResult) { success -> + if (success) { + //for API 30+, navigation is handled in 'onDeleteFolderResultReceived' + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + FolderDeletionHelper.showSuccess(this, "Folder deleted successfully", bucketName) + navigateToCustomSelector() + } + } else { + FolderDeletionHelper.showError(this, "Failed to delete folder", bucketName) + } + } + } + + + + /** + * 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) + + //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() + + //reset toolbar and flags + isImageFragmentOpen = false + showOverflowMenu = false + setUpToolbar() + changeTitle(getString(R.string.custom_selector_title), 0) + + //fetch updated folder data + fetchData() + } + + + /** + * override on folder click, + * change the toolbar title on folder click, make overflow menu visible */ override fun onFolderClick( folderId: Long, @@ -441,6 +552,11 @@ class CustomSelectorActivity : bucketId = folderId bucketName = folderName isImageFragmentOpen = true + + //show the overflow menu only when a folder is clicked + showOverflowMenu = true + setUpToolbar() + } /** @@ -556,6 +672,10 @@ class CustomSelectorActivity : isImageFragmentOpen = false changeTitle(getString(R.string.custom_selector_title), 0) } + + //hide overflow menu when not in folder + showOverflowMenu = false + setUpToolbar() } /** diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java b/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java index 4a5823252..97a16acc3 100644 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java @@ -20,4 +20,4 @@ public interface Constants { String COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos"; String COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images"; } -} \ No newline at end of file +} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java index 5e631425b..20fc831a8 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java @@ -12,6 +12,7 @@ import android.net.Uri; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; +import android.util.Log; import android.view.View; import android.widget.AdapterView; import android.widget.AdapterView.OnItemClickListener; @@ -59,6 +60,7 @@ import java.util.Locale; import java.util.Map; import javax.inject.Inject; import javax.inject.Named; +import timber.log.Timber; public class SettingsFragment extends PreferenceFragmentCompat { @@ -81,6 +83,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { private ListPreference themeListPreference; private Preference descriptionLanguageListPreference; private Preference appUiLanguageListPreference; + private Preference showDeletionButtonPreference; private String keyLanguageListPreference; private TextView recentLanguagesTextView; private View separator; @@ -188,6 +191,18 @@ public class SettingsFragment extends PreferenceFragmentCompat { } }); + // + showDeletionButtonPreference = findPreference("displayDeletionButton"); + if (showDeletionButtonPreference != null) { + showDeletionButtonPreference.setOnPreferenceChangeListener((preference, newValue) -> { + boolean isEnabled = (boolean) newValue; + // Save preference when user toggles the button + defaultKvStore.putBoolean("displayDeletionButton", isEnabled); + return true; + }); + } + + Preference betaTesterPreference = findPreference("becomeBetaTester"); betaTesterPreference.setOnPreferenceClickListener(preference -> { Utils.handleWebUrl(getActivity(), Uri.parse(getResources().getString(R.string.beta_opt_in_link))); diff --git a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java index 828ef2338..692194234 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java +++ b/app/src/main/java/fr/free/nrw/commons/utils/PermissionUtils.java @@ -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 }; diff --git a/app/src/main/res/layout/activity_custom_selector.xml b/app/src/main/res/layout/activity_custom_selector.xml index 02c864422..44e494cb9 100644 --- a/app/src/main/res/layout/activity_custom_selector.xml +++ b/app/src/main/res/layout/activity_custom_selector.xml @@ -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" /> + + + + + app:layout_constraintStart_toEndOf="@id/image_limit_error" + app:srcCompat="@drawable/ic_overflow" /> + \ No newline at end of file diff --git a/app/src/main/res/menu/menu_custom_selector.xml b/app/src/main/res/menu/menu_custom_selector.xml new file mode 100644 index 000000000..fc432439e --- /dev/null +++ b/app/src/main/res/menu/menu_custom_selector.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f9de8e051..187c5fc96 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,6 +117,7 @@ Search categories Search for items that your media depicts (mountain, Taj Mahal, etc.) Save + Overflow menu Refresh List (No uploads yet) @@ -832,6 +833,17 @@ Upload your first media by tapping on the add button. Pending Failed Could not load place data + + Delete Folder + Confirm Deletion + Are you sure you want to delete folder %1$s containing %2$d items? + Delete + Cancel + Folder %1$s deleted successfully + Failed to delete folder %1$s + Error in trashing folder contents: %1$s + Failed to retrieve folder path for bucket ID: %1$d + This place has no picture yet, go take one! This place has a picture already. Now checking whether this place has a picture. diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 8ac890545..5d2dabb59 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -51,6 +51,13 @@ android:summary="@string/display_campaigns_explanation" android:title="@string/display_campaigns" /> + +