2 * Copyright 2016-2023 Soren Stoutner <soren@stoutner.com>.
4 * This file is part of Privacy Browser Android <https://www.stoutner.com/privacy-browser-android>.
6 * Privacy Browser Android is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
11 * Privacy Browser Android is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License
17 * along with Privacy Browser Android. If not, see <http://www.gnu.org/licenses/>.
20 package com.stoutner.privacybrowser.dialogs
22 import android.app.Dialog
23 import android.content.Context
24 import android.content.DialogInterface
25 import android.database.Cursor
26 import android.database.MatrixCursor
27 import android.database.MergeCursor
28 import android.graphics.Bitmap
29 import android.graphics.BitmapFactory
30 import android.os.Bundle
31 import android.text.Editable
32 import android.text.TextWatcher
33 import android.view.KeyEvent
34 import android.view.View
35 import android.view.WindowManager
36 import android.widget.AdapterView
37 import android.widget.Button
38 import android.widget.EditText
39 import android.widget.ImageView
40 import android.widget.LinearLayout
41 import android.widget.RadioButton
42 import android.widget.Spinner
43 import android.widget.TextView
45 import androidx.appcompat.app.AlertDialog
46 import androidx.core.content.ContextCompat
47 import androidx.cursoradapter.widget.ResourceCursorAdapter
48 import androidx.fragment.app.DialogFragment
49 import androidx.preference.PreferenceManager
51 import com.stoutner.privacybrowser.R
52 import com.stoutner.privacybrowser.activities.HOME_FOLDER_DATABASE_ID
53 import com.stoutner.privacybrowser.activities.HOME_FOLDER_ID
54 import com.stoutner.privacybrowser.helpers.BOOKMARK_NAME
55 import com.stoutner.privacybrowser.helpers.DISPLAY_ORDER
56 import com.stoutner.privacybrowser.helpers.FAVORITE_ICON
57 import com.stoutner.privacybrowser.helpers.FOLDER_ID
58 import com.stoutner.privacybrowser.helpers.ID
59 import com.stoutner.privacybrowser.helpers.PARENT_FOLDER_ID
60 import com.stoutner.privacybrowser.helpers.BookmarksDatabaseHelper
62 import java.io.ByteArrayOutputStream
64 // Define the class constants.
65 private const val DATABASE_ID = "database_id"
66 private const val FAVORITE_ICON_BYTE_ARRAY = "favorite_icon_byte_array"
68 class EditBookmarkFolderDatabaseViewDialog : DialogFragment() {
70 fun folderDatabaseId(databaseId: Int, favoriteIconBitmap: Bitmap): EditBookmarkFolderDatabaseViewDialog {
71 // Create a favorite icon byte array output stream.
72 val favoriteIconByteArrayOutputStream = ByteArrayOutputStream()
74 // Convert the favorite icon to a PNG and place it in the byte array output stream. `0` is for lossless compression (the only option for a PNG).
75 favoriteIconBitmap.compress(Bitmap.CompressFormat.PNG, 0, favoriteIconByteArrayOutputStream)
77 // Convert the byte array output stream to a byte array.
78 val favoriteIconByteArray = favoriteIconByteArrayOutputStream.toByteArray()
80 // Create an arguments bundle.
81 val argumentsBundle = Bundle()
83 // Store the variables in the bundle.
84 argumentsBundle.putInt(DATABASE_ID, databaseId)
85 argumentsBundle.putByteArray(FAVORITE_ICON_BYTE_ARRAY, favoriteIconByteArray)
87 // Create a new instance of the dialog.
88 val editBookmarkFolderDatabaseViewDialog = EditBookmarkFolderDatabaseViewDialog()
90 // Add the arguments bundle to the dialog.
91 editBookmarkFolderDatabaseViewDialog.arguments = argumentsBundle
93 // Return the new dialog.
94 return editBookmarkFolderDatabaseViewDialog
98 // Declare the class variables.
99 private lateinit var editBookmarkFolderDatabaseViewListener: EditBookmarkFolderDatabaseViewListener
101 // Declare the class views.
102 private lateinit var currentIconRadioButton: RadioButton
103 private lateinit var nameEditText: EditText
104 private lateinit var parentFolderSpinner: Spinner
105 private lateinit var displayOrderEditText: EditText
106 private lateinit var saveButton: Button
108 // The public interface is used to send information back to the parent activity.
109 interface EditBookmarkFolderDatabaseViewListener {
110 fun saveBookmarkFolder(dialogFragment: DialogFragment, selectedFolderDatabaseId: Int, favoriteIconBitmap: Bitmap)
113 override fun onAttach(context: Context) {
114 // Run the default commands.
115 super.onAttach(context)
117 // Get a handle for edit bookmark database view listener from the launching context.
118 editBookmarkFolderDatabaseViewListener = context as EditBookmarkFolderDatabaseViewListener
121 override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
122 // Get a handle for the arguments.
123 val arguments = requireArguments()
125 // Get the variables from the arguments.
126 val folderDatabaseId = arguments.getInt(DATABASE_ID)
127 val favoriteIconByteArray = arguments.getByteArray(FAVORITE_ICON_BYTE_ARRAY)!!
129 // Convert the favorite icon byte array to a bitmap.
130 val favoriteIconBitmap = BitmapFactory.decodeByteArray(favoriteIconByteArray, 0, favoriteIconByteArray.size)
132 // Initialize the bookmarks database helper.
133 val bookmarksDatabaseHelper = BookmarksDatabaseHelper(requireContext())
135 // Get a cursor with the selected bookmark.
136 val folderCursor = bookmarksDatabaseHelper.getBookmark(folderDatabaseId)
138 // Move the cursor to the first position.
139 folderCursor.moveToFirst()
141 // Use an alert dialog builder to create the alert dialog.
142 val dialogBuilder = AlertDialog.Builder(requireContext(), R.style.PrivacyBrowserAlertDialog)
145 dialogBuilder.setTitle(R.string.edit_folder)
148 dialogBuilder.setView(R.layout.edit_bookmark_folder_databaseview_dialog)
150 // Set the cancel button listener. Using `null` as the listener closes the dialog without doing anything else.
151 dialogBuilder.setNegativeButton(R.string.cancel, null)
153 // Set the save button listener.
154 dialogBuilder.setPositiveButton(R.string.save) { _: DialogInterface?, _: Int ->
155 // Return the dialog fragment to the parent activity.
156 editBookmarkFolderDatabaseViewListener.saveBookmarkFolder(this, folderDatabaseId, favoriteIconBitmap)
159 // Create an alert dialog from the alert dialog builder.
160 val alertDialog = dialogBuilder.create()
162 // Get a handle for the shared preferences.
163 val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
165 // Get the screenshot preference.
166 val allowScreenshots = sharedPreferences.getBoolean(getString(R.string.allow_screenshots_key), false)
168 // Disable screenshots if not allowed.
169 if (!allowScreenshots) {
170 alertDialog.window!!.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
173 // The alert dialog must be shown before items in the layout can be modified.
176 // Get handles for the views in the alert dialog.
177 val databaseIdTextView = alertDialog.findViewById<TextView>(R.id.folder_database_id_textview)!!
178 val folderIdTextView = alertDialog.findViewById<TextView>(R.id.folder_id_textview)!!
179 val currentIconLinearLayout = alertDialog.findViewById<LinearLayout>(R.id.current_icon_linearlayout)!!
180 currentIconRadioButton = alertDialog.findViewById(R.id.current_icon_radiobutton)!!
181 val currentIconImageView = alertDialog.findViewById<ImageView>(R.id.current_icon_imageview)!!
182 val defaultIconLinearLayout = alertDialog.findViewById<LinearLayout>(R.id.default_icon_linearlayout)!!
183 val defaultIconRadioButton = alertDialog.findViewById<RadioButton>(R.id.default_icon_radiobutton)!!
184 val webpageFavoriteIconLinearLayout = alertDialog.findViewById<LinearLayout>(R.id.webpage_favorite_icon_linearlayout)!!
185 val webpageFavoriteIconRadioButton = alertDialog.findViewById<RadioButton>(R.id.webpage_favorite_icon_radiobutton)!!
186 val webpageFavoriteIconImageView = alertDialog.findViewById<ImageView>(R.id.webpage_favorite_icon_imageview)!!
187 nameEditText = alertDialog.findViewById(R.id.folder_name_edittext)!!
188 parentFolderSpinner = alertDialog.findViewById(R.id.parent_folder_spinner)!!
189 displayOrderEditText = alertDialog.findViewById(R.id.display_order_edittext)!!
190 saveButton = alertDialog.getButton(AlertDialog.BUTTON_POSITIVE)
192 // Store the current folder values.
193 val currentFolderName = folderCursor.getString(folderCursor.getColumnIndexOrThrow(BOOKMARK_NAME))
194 val currentDisplayOrder = folderCursor.getInt(folderCursor.getColumnIndexOrThrow(DISPLAY_ORDER))
195 val parentFolderId = folderCursor.getLong(folderCursor.getColumnIndexOrThrow(PARENT_FOLDER_ID))
196 val currentFolderId = folderCursor.getLong(folderCursor.getColumnIndexOrThrow(FOLDER_ID))
198 // Populate the database ID text view.
199 databaseIdTextView.text = folderCursor.getInt(folderCursor.getColumnIndexOrThrow(ID)).toString()
201 // Populate the folder ID text view.
202 folderIdTextView.text = folderCursor.getLong(folderCursor.getColumnIndexOrThrow(FOLDER_ID)).toString()
204 // Get the current favorite icon byte array from the cursor.
205 val currentIconByteArray = folderCursor.getBlob(folderCursor.getColumnIndexOrThrow(FAVORITE_ICON))
207 // Convert the byte array to a bitmap beginning at the first byte and ending at the last.
208 val currentIconBitmap = BitmapFactory.decodeByteArray(currentIconByteArray, 0, currentIconByteArray.size)
210 // Populate the current icon image view.
211 currentIconImageView.setImageBitmap(currentIconBitmap)
213 // Populate the webpage favorite icon image view.
214 webpageFavoriteIconImageView.setImageBitmap(favoriteIconBitmap)
216 // Populate the folder name edit text.
217 nameEditText.setText(currentFolderName)
219 // Define an array of matrix cursor column names.
220 val matrixCursorColumnNames = arrayOf(ID, BOOKMARK_NAME, PARENT_FOLDER_ID)
222 // Create a matrix cursor.
223 val matrixCursor = MatrixCursor(matrixCursorColumnNames)
225 // Add `Home Folder` to the matrix cursor.
226 matrixCursor.addRow(arrayOf(HOME_FOLDER_DATABASE_ID, getString(R.string.home_folder), HOME_FOLDER_ID))
228 // Create a list of folder IDs.
229 val currentAndSubfolderIds = mutableListOf<Long>()
231 // Add the current folder ID to the list.
232 currentAndSubfolderIds.add(currentFolderId)
234 // Get a long array of all the subfolders IDs.
235 val subfolderIdLongList = getListOfSubfolderIds(currentFolderId, bookmarksDatabaseHelper)
237 // Add the subfolder IDs to the list.
238 for (subfolderId in subfolderIdLongList)
239 currentAndSubfolderIds.add(subfolderId)
241 // Get a cursor with the list of all the folders except for those specified..
242 val foldersCursor = bookmarksDatabaseHelper.getFoldersExcept(currentAndSubfolderIds)
244 // Combine the matrix cursor and the folders cursor.
245 val combinedFoldersCursor = MergeCursor(arrayOf(matrixCursor, foldersCursor))
247 // Create a resource cursor adapter for the spinner.
248 val foldersCursorAdapter: ResourceCursorAdapter = object: ResourceCursorAdapter(context, R.layout.databaseview_spinner_item, combinedFoldersCursor, 0) {
249 override fun bindView(view: View, context: Context, cursor: Cursor) {
250 // Get handles for the spinner views.
251 val subfolderSpacerTextView = view.findViewById<TextView>(R.id.subfolder_spacer_textview)
252 val folderIconImageView = view.findViewById<ImageView>(R.id.folder_icon_imageview)
253 val folderNameTextView = view.findViewById<TextView>(R.id.folder_name_textview)
255 // Populate the subfolder spacer if it is not null (the spinner is open).
256 if (subfolderSpacerTextView != null) {
257 // Indent subfolders.
258 if (cursor.getLong(cursor.getColumnIndexOrThrow(PARENT_FOLDER_ID)) != HOME_FOLDER_ID) { // The folder is not in the home folder.
259 // Get the subfolder spacer.
260 subfolderSpacerTextView.text = bookmarksDatabaseHelper.getSubfolderSpacer(cursor.getLong(cursor.getColumnIndexOrThrow(FOLDER_ID)))
261 } else { // The folder is in the home folder.
262 // Reset the subfolder spacer.
263 subfolderSpacerTextView.text = ""
267 // Set the folder icon according to the type.
268 if (combinedFoldersCursor.position == 0) { // Set the `Home Folder` icon.
269 // Set the gray folder image. `ContextCompat` must be used until the minimum API >= 21.
270 folderIconImageView.setImageDrawable(ContextCompat.getDrawable(context, R.drawable.folder_gray))
271 } else { // Set a user folder icon.
272 // Get the folder icon byte array.
273 val folderIconByteArray = cursor.getBlob(cursor.getColumnIndexOrThrow(FAVORITE_ICON))
275 // Convert the byte array to a bitmap beginning at the first byte and ending at the last.
276 val folderIconBitmap = BitmapFactory.decodeByteArray(folderIconByteArray, 0, folderIconByteArray.size)
278 // Set the folder icon.
279 folderIconImageView.setImageBitmap(folderIconBitmap)
282 // Set the folder name.
283 folderNameTextView.text = cursor.getString(cursor.getColumnIndexOrThrow(BOOKMARK_NAME))
287 // Set the folders cursor adapter drop drown view resource.
288 foldersCursorAdapter.setDropDownViewResource(R.layout.databaseview_spinner_dropdown_items)
290 // Set the parent folder spinner adapter.
291 parentFolderSpinner.adapter = foldersCursorAdapter
293 // Select the current folder in the spinner if the bookmark isn't in the home folder.
294 if (parentFolderId != HOME_FOLDER_ID) {
295 // Get the database ID of the parent folder as a long.
296 val parentFolderDatabaseId = bookmarksDatabaseHelper.getFolderDatabaseId(parentFolderId).toLong()
298 // Initialize the parent folder position and the iteration variable.
299 var parentFolderPosition = 0
302 // Find the parent folder position in the folders cursor adapter.
304 if (foldersCursorAdapter.getItemId(i) == parentFolderDatabaseId) {
305 // Store the current position for the parent folder.
306 parentFolderPosition = i
308 // Try the next entry.
311 // Stop when the parent folder position is found or all the items in the folders cursor adapter have been checked.
312 } while (parentFolderPosition == 0 && i < foldersCursorAdapter.count)
314 // Select the parent folder in the spinner.
315 parentFolderSpinner.setSelection(parentFolderPosition)
318 // Store the current folder database ID.
319 val currentParentFolderDatabaseId = parentFolderSpinner.selectedItemId.toInt()
321 // Populate the display order edit text.
322 displayOrderEditText.setText(folderCursor.getInt(folderCursor.getColumnIndexOrThrow(DISPLAY_ORDER)).toString())
324 // Initially disable the edit button.
325 saveButton.isEnabled = false
327 // Set the radio button listeners. These perform a click on the linear layout, which contains the necessary logic.
328 currentIconRadioButton.setOnClickListener { currentIconLinearLayout.performClick() }
329 defaultIconRadioButton.setOnClickListener { defaultIconLinearLayout.performClick() }
330 webpageFavoriteIconRadioButton.setOnClickListener { webpageFavoriteIconLinearLayout.performClick() }
332 // Set the current icon linear layout click listener.
333 currentIconLinearLayout.setOnClickListener {
334 // Check the current icon radio button.
335 currentIconRadioButton.isChecked = true
337 // Uncheck the other radio buttons.
338 defaultIconRadioButton.isChecked = false
339 webpageFavoriteIconRadioButton.isChecked = false
341 // Update the save button.
342 updateSaveButton(currentFolderName, currentParentFolderDatabaseId, currentDisplayOrder)
345 // Set the default icon linear layout click listener.
346 defaultIconLinearLayout.setOnClickListener {
347 // Check the default icon radio button.
348 defaultIconRadioButton.isChecked = true
350 // Uncheck the other radio buttons.
351 currentIconRadioButton.isChecked = false
352 webpageFavoriteIconRadioButton.isChecked = false
354 // Update the save button.
355 updateSaveButton(currentFolderName, currentParentFolderDatabaseId, currentDisplayOrder)
358 // Set the webpage favorite icon linear layout click listener.
359 webpageFavoriteIconLinearLayout.setOnClickListener {
360 // Check the webpage favorite icon radio button.
361 webpageFavoriteIconRadioButton.isChecked = true
363 // Uncheck the other radio buttons.
364 currentIconRadioButton.isChecked = false
365 defaultIconRadioButton.isChecked = false
367 // Update the save button.
368 updateSaveButton(currentFolderName, currentParentFolderDatabaseId, currentDisplayOrder)
371 // Update the save button if the bookmark name changes.
372 nameEditText.addTextChangedListener(object: TextWatcher {
373 override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
377 override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
381 override fun afterTextChanged(s: Editable) {
382 // Update the save button.
383 updateSaveButton(currentFolderName, currentParentFolderDatabaseId, currentDisplayOrder)
387 // Wait to set the on item selected listener until the spinner has been inflated. Otherwise the dialog will crash on restart.
388 parentFolderSpinner.post {
389 // Update the save button if the parent folder changes.
390 parentFolderSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
391 override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) {
392 // Update the save button.
393 updateSaveButton(currentFolderName, currentParentFolderDatabaseId, currentDisplayOrder)
396 override fun onNothingSelected(parent: AdapterView<*>) {
402 // Update the save button if the display order changes.
403 displayOrderEditText.addTextChangedListener(object: TextWatcher {
404 override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
408 override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
412 override fun afterTextChanged(s: Editable) {
413 // Update the save button.
414 updateSaveButton(currentFolderName, currentParentFolderDatabaseId, currentDisplayOrder)
418 // Allow the enter key on the keyboard to save the bookmark from the bookmark name edit text.
419 nameEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
420 // Check the key code, event, and button status.
421 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER && saveButton.isEnabled) { // The enter key was pressed and the save button is enabled.
422 // Trigger the listener and return the dialog fragment to the parent activity.
423 editBookmarkFolderDatabaseViewListener.saveBookmarkFolder(this, folderDatabaseId, favoriteIconBitmap)
425 // Manually dismiss the alert dialog.
426 alertDialog.dismiss()
428 // Consume the event.
429 return@setOnKeyListener true
430 } else { // If any other key was pressed, or if the save button is currently disabled, do not consume the event.
431 return@setOnKeyListener false
435 // Allow the enter key on the keyboard to save the bookmark from the display order edit text.
436 displayOrderEditText.setOnKeyListener { _: View?, keyCode: Int, event: KeyEvent ->
437 // Check the key code, event, and button status.
438 if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_ENTER && saveButton.isEnabled) { // The enter key was pressed and the save button is enabled.
439 // Trigger the listener and return the dialog fragment to the parent activity.
440 editBookmarkFolderDatabaseViewListener.saveBookmarkFolder(this, folderDatabaseId, favoriteIconBitmap)
442 // Manually dismiss the alert dialog.
443 alertDialog.dismiss()
445 // Consume the event.
446 return@setOnKeyListener true
447 } else { // If any other key was pressed, or if the save button is currently disabled, do not consume the event.
448 return@setOnKeyListener false
452 // Return the alert dialog.
456 private fun updateSaveButton(currentFolderName: String, currentParentFolderDatabaseId: Int, currentDisplayOrder: Int) {
457 // Get the values from the views.
458 val newFolderName = nameEditText.text.toString()
459 val newParentFolderDatabaseId = parentFolderSpinner.selectedItemId.toInt()
460 val newDisplayOrder = displayOrderEditText.text.toString()
462 // Has the favorite icon changed?
463 val iconChanged = !currentIconRadioButton.isChecked
465 // Has the folder been renamed?
466 val folderRenamed = (newFolderName != currentFolderName)
468 // Has the parent folder changed?
469 val parentFolderChanged = newParentFolderDatabaseId != currentParentFolderDatabaseId
471 // Has the display order changed?
472 val displayOrderChanged = newDisplayOrder != currentDisplayOrder.toString()
474 // Update the enabled status of the edit button.
475 saveButton.isEnabled = (iconChanged || folderRenamed || parentFolderChanged || displayOrderChanged) && newFolderName.isNotBlank() && newDisplayOrder.isNotBlank()
478 private fun getListOfSubfolderIds(folderId: Long, bookmarksDatabaseHelper: BookmarksDatabaseHelper): List<Long> {
479 // Create a subfolder long list.
480 val subfolderIdLongList = mutableListOf<Long>()
482 // Get a cursor with all the immediate subfolders.
483 val subfoldersCursor = bookmarksDatabaseHelper.getSubfolderNamesAndFolderIds(folderId)
485 // Populate the subfolder list.
486 for (i in 0 until subfoldersCursor.count) {
487 // Move the subfolder cursor to the current item.
488 subfoldersCursor.moveToPosition(i)
490 // Get the subfolder ID.
491 val subfolderId = subfoldersCursor.getLong(subfoldersCursor.getColumnIndexOrThrow(FOLDER_ID))
493 // Add the folder ID to the list.
494 subfolderIdLongList.add(subfolderId)
496 // Get a list of any subfolders of the subfolder.
497 val nestedSubfolderIdList = getListOfSubfolderIds(subfolderId, bookmarksDatabaseHelper)
499 // Add each of the subfolder IDs to the list.
500 for (nestedSubfolderId in nestedSubfolderIdList)
501 subfolderIdLongList.add(nestedSubfolderId)
504 // Return the list of subfolder IDs.
505 return subfolderIdLongList