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