Skip to content

Commit 634bc3e

Browse files
custom-selector: adds a button to delete the current folder in custom selector (commons-app#5925)
* Issue commons-app#5811: "Delete folder" menu in custom image selector * Issue 5811: folder deletion for api < 29. * Issue 5811: folder deletion for api < 29. * Issue 5811: folder deletion for api 29. * Issue 5811: folder deletion * Issue 5811: fixes merge conflicts, replaces used function onActivityResult with an ActivityResultLauncher * Update Constants.java --------- Co-authored-by: Nicolas Raoul <[email protected]>
1 parent 17a8845 commit 634bc3e

File tree

11 files changed

+447
-12
lines changed

11 files changed

+447
-12
lines changed

app/build.gradle

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,6 @@ dependencies {
9393
implementation "org.jetbrains.kotlin:kotlin-reflect:$KOTLIN_VERSION"
9494

9595
//Mocking
96-
testImplementation("io.mockk:mockk:1.13.4")
9796
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0'
9897
testImplementation 'org.mockito:mockito-inline:5.2.0'
9998
testImplementation 'org.mockito:mockito-core:5.6.0'
@@ -367,11 +366,11 @@ android {
367366

368367

369368
compileOptions {
370-
sourceCompatibility JavaVersion.VERSION_11
371-
targetCompatibility JavaVersion.VERSION_11
369+
sourceCompatibility JavaVersion.VERSION_17
370+
targetCompatibility JavaVersion.VERSION_17
372371
}
373372
kotlinOptions {
374-
jvmTarget = "11"
373+
jvmTarget = "17"
375374
}
376375

377376
buildToolsVersion buildToolsVersion
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
package fr.free.nrw.commons.customselector.helper
2+
3+
import android.content.ContentUris
4+
import android.content.Context
5+
import android.media.MediaScannerConnection
6+
import android.net.Uri
7+
import android.os.Build
8+
import android.provider.MediaStore
9+
import android.widget.Toast
10+
import androidx.activity.result.ActivityResultLauncher
11+
import androidx.activity.result.IntentSenderRequest
12+
import androidx.appcompat.app.AlertDialog
13+
import fr.free.nrw.commons.R
14+
import timber.log.Timber
15+
import java.io.File
16+
17+
object FolderDeletionHelper {
18+
19+
/**
20+
* Prompts the user to confirm deletion of a specified folder and, if confirmed, deletes it.
21+
*
22+
* @param context The context used to show the confirmation dialog and manage deletion.
23+
* @param folder The folder to be deleted.
24+
* @param onDeletionComplete Callback invoked with `true` if the folder was
25+
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
26+
* successfully deleted, `false` otherwise.
27+
*/
28+
fun confirmAndDeleteFolder(
29+
context: Context,
30+
folder: File,
31+
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>,
32+
onDeletionComplete: (Boolean) -> Unit) {
33+
val itemCount = countItemsInFolder(context, folder)
34+
val folderPath = folder.absolutePath
35+
36+
//don't show this dialog on API 30+, it's handled automatically using MediaStore
37+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
38+
val success = deleteFolderMain(context, folder, trashFolderLauncher)
39+
onDeletionComplete(success)
40+
41+
} else {
42+
AlertDialog.Builder(context)
43+
.setTitle(context.getString(R.string.custom_selector_confirm_deletion_title))
44+
.setMessage(context.getString(R.string.custom_selector_confirm_deletion_message, folderPath, itemCount))
45+
.setPositiveButton(context.getString(R.string.custom_selector_delete)) { _, _ ->
46+
47+
//proceed with deletion if user confirms
48+
val success = deleteFolderMain(context, folder, trashFolderLauncher)
49+
onDeletionComplete(success)
50+
}
51+
.setNegativeButton(context.getString(R.string.custom_selector_cancel)) { dialog, _ ->
52+
dialog.dismiss()
53+
onDeletionComplete(false)
54+
}
55+
.show()
56+
}
57+
}
58+
59+
/**
60+
* Deletes the specified folder, handling different Android storage models based on the API
61+
*
62+
* @param context The context used to manage storage operations.
63+
* @param folder The folder to delete.
64+
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
65+
* @return `true` if the folder deletion was successful, `false` otherwise.
66+
*/
67+
private fun deleteFolderMain(
68+
context: Context,
69+
folder: File,
70+
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
71+
{
72+
return when {
73+
//for API 30 and above, use MediaStore
74+
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> trashFolderContents(context, folder, trashFolderLauncher)
75+
76+
//for API 29 ('requestLegacyExternalStorage' is set to true in Manifest)
77+
// and below use file system
78+
else -> deleteFolderLegacy(folder)
79+
}
80+
}
81+
82+
/**
83+
* Moves all contents of a specified folder to the trash on devices running
84+
* Android 11 (API level 30) and above.
85+
*
86+
* @param context The context used to access the content resolver.
87+
* @param folder The folder whose contents are to be moved to the trash.
88+
* @param trashFolderLauncher An ActivityResultLauncher for handling the result of the trash request.
89+
* @return `true` if the trash request was initiated successfully, `false` otherwise.
90+
*/
91+
private fun trashFolderContents(
92+
context: Context,
93+
folder: File,
94+
trashFolderLauncher: ActivityResultLauncher<IntentSenderRequest>): Boolean
95+
{
96+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
97+
98+
val contentResolver = context.contentResolver
99+
val folderPath = folder.absolutePath
100+
val urisToTrash = mutableListOf<Uri>()
101+
102+
// Use URIs specific to media items
103+
val mediaUris = listOf(
104+
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
105+
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
106+
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
107+
)
108+
109+
for (mediaUri in mediaUris) {
110+
val selection = "${MediaStore.MediaColumns.DATA} LIKE ?"
111+
val selectionArgs = arrayOf("$folderPath/%")
112+
113+
contentResolver.query(mediaUri, arrayOf(MediaStore.MediaColumns._ID), selection,
114+
selectionArgs, null)
115+
?.use{ cursor ->
116+
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)
117+
while (cursor.moveToNext()) {
118+
val id = cursor.getLong(idColumn)
119+
val fileUri = ContentUris.withAppendedId(mediaUri, id)
120+
urisToTrash.add(fileUri)
121+
}
122+
}
123+
}
124+
125+
//proceed with trashing if we have valid URIs
126+
if (urisToTrash.isNotEmpty()) {
127+
try {
128+
val trashRequest = MediaStore.createTrashRequest(contentResolver, urisToTrash, true)
129+
val intentSenderRequest = IntentSenderRequest.Builder(trashRequest.intentSender).build()
130+
trashFolderLauncher.launch(intentSenderRequest)
131+
return true
132+
} catch (e: SecurityException) {
133+
Timber.tag("DeleteFolder").e(context.getString(R.string.custom_selector_error_trashing_folder_contents, e.message))
134+
}
135+
}
136+
return false
137+
}
138+
139+
140+
/**
141+
* Counts the number of items in a specified folder, including items in subfolders.
142+
*
143+
* @param context The context used to access the content resolver.
144+
* @param folder The folder in which to count items.
145+
* @return The total number of items in the folder.
146+
*/
147+
private fun countItemsInFolder(context: Context, folder: File): Int {
148+
val contentResolver = context.contentResolver
149+
val folderPath = folder.absolutePath
150+
val uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
151+
val selection = "${MediaStore.Images.Media.DATA} LIKE ?"
152+
val selectionArgs = arrayOf("$folderPath/%")
153+
154+
return contentResolver.query(
155+
uri,
156+
arrayOf(MediaStore.Images.Media._ID),
157+
selection,
158+
selectionArgs,
159+
null)?.use { cursor ->
160+
cursor.count
161+
} ?: 0
162+
}
163+
164+
165+
166+
/**
167+
* Refreshes the MediaStore for a specified folder, updating the system to recognize any changes
168+
*
169+
* @param context The context used to access the MediaScannerConnection.
170+
* @param folder The folder to refresh in the MediaStore.
171+
*/
172+
fun refreshMediaStore(context: Context, folder: File) {
173+
MediaScannerConnection.scanFile(
174+
context,
175+
arrayOf(folder.absolutePath),
176+
null
177+
) { _, _ -> }
178+
}
179+
180+
181+
182+
/**
183+
* Deletes a specified folder and all of its contents on devices running
184+
* Android 10 (API level 29) and below.
185+
*
186+
* @param folder The `File` object representing the folder to be deleted.
187+
* @return `true` if the folder and all contents were deleted successfully; `false` otherwise.
188+
*/
189+
private fun deleteFolderLegacy(folder: File): Boolean {
190+
return folder.deleteRecursively()
191+
}
192+
193+
194+
/**
195+
* Retrieves the absolute path of a folder given its unique identifier (bucket ID).
196+
*
197+
* @param context The context used to access the content resolver.
198+
* @param folderId The unique identifier (bucket ID) of the folder.
199+
* @return The absolute path of the folder as a `String`, or `null` if the folder is not found.
200+
*/
201+
fun getFolderPath(context: Context, folderId: Long): String? {
202+
val projection = arrayOf(MediaStore.Images.Media.DATA)
203+
val selection = "${MediaStore.Images.Media.BUCKET_ID} = ?"
204+
val selectionArgs = arrayOf(folderId.toString())
205+
206+
context.contentResolver.query(
207+
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
208+
projection,
209+
selection,
210+
selectionArgs,
211+
null
212+
)?.use { cursor ->
213+
if (cursor.moveToFirst()) {
214+
val fullPath = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA))
215+
return File(fullPath).parent
216+
}
217+
}
218+
return null
219+
}
220+
221+
/**
222+
* Displays an error message to the user and logs it for debugging purposes.
223+
*
224+
* @param context The context used to display the Toast.
225+
* @param message The error message to display and log.
226+
* @param folderName The name of the folder to delete.
227+
*/
228+
fun showError(context: Context, message: String, folderName: String) {
229+
Toast.makeText(context,
230+
context.getString(R.string.custom_selector_folder_deleted_failure, folderName),
231+
Toast.LENGTH_SHORT).show()
232+
Timber.tag("DeleteFolder").e(message)
233+
}
234+
235+
/**
236+
* Displays a success message to the user.
237+
*
238+
* @param context The context used to display the Toast.
239+
* @param message The success message to display.
240+
* @param folderName The name of the folder to delete.
241+
*/
242+
fun showSuccess(context: Context, message: String, folderName: String) {
243+
Toast.makeText(context,
244+
context.getString(R.string.custom_selector_folder_deleted_success, folderName),
245+
Toast.LENGTH_SHORT).show()
246+
Timber.tag("DeleteFolder").d(message)
247+
}
248+
249+
}

0 commit comments

Comments
 (0)