Add support for category renaming (#854)

* rename category

* changed CHANGELOG

* IDE Refactor

* Address review comments

Signed-off-by: Harsh Shandilya <me@msfjarvis.dev>

* change Stack to List and fix bug when empty category name

* create intermediate folders

* little fixes and KDoc added

* Reuse existing move code

* change button Cancel => Skip

* use canonicalPath to confirm destination inside repository

* change error message

* update KDoc

* show different error to user

Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
Co-authored-by: Harsh Shandilya <msfjarvis@gmail.com>
Co-authored-by: Fabian Henneke <fabian@henneke.me>
Co-authored-by: Fabian Henneke <FabianHenneke@users.noreply.github.com>
This commit is contained in:
Diogenes Molinares 2020-06-18 14:07:26 +02:00 committed by GitHub
parent 33b3f54921
commit 23b488a8eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 91 additions and 6 deletions

View file

@ -12,7 +12,7 @@ All notable changes to this project will be documented in this file.
- Add support for better, more secure Keyex's and MACs with a brand new SSH backend - Add support for better, more secure Keyex's and MACs with a brand new SSH backend
- Allow manually marking domains for subdomain-level association. This will allow you to keep separate passwords for `site1.example.com` and `site2.example.com` and have them show as such in Autofill. - Allow manually marking domains for subdomain-level association. This will allow you to keep separate passwords for `site1.example.com` and `site2.example.com` and have them show as such in Autofill.
- Provide better messages for OpenKeychain errors - Provide better messages for OpenKeychain errors
- Rename passwords - Rename passwords and categories
### Changed ### Changed
- **BREAKING**: Remove support for HOTP/TOTP secrets - Please use FIDO keys or a dedicated app like [Aegis](https://github.com/beemdevelopment/Aegis) or [andOTP](https://github.com/andOTP/andOTP) - **BREAKING**: Remove support for HOTP/TOTP secrets - Please use FIDO keys or a dedicated app like [Aegis](https://github.com/beemdevelopment/Aegis) or [andOTP](https://github.com/andOTP/andOTP)

View file

@ -154,6 +154,9 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
// Called each time the action mode is shown. Always called after onCreateActionMode, but // Called each time the action mode is shown. Always called after onCreateActionMode, but
// may be called multiple times if the mode is invalidated. // may be called multiple times if the mode is invalidated.
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
menu.findItem(R.id.menu_edit_password).isVisible =
recyclerAdapter.getSelectedItems(requireContext())
.all { it.type == PasswordItem.TYPE_CATEGORY }
return true return true
} }
@ -170,6 +173,11 @@ class PasswordFragment : Fragment(R.layout.password_recycler_view) {
requireStore().movePasswords(recyclerAdapter.getSelectedItems(requireContext())) requireStore().movePasswords(recyclerAdapter.getSelectedItems(requireContext()))
false false
} }
R.id.menu_edit_password -> {
requireStore().renameCategory(recyclerAdapter.getSelectedItems(requireContext()))
mode.finish()
false
}
else -> false else -> false
} }
} }

View file

@ -43,6 +43,7 @@ import com.github.ajalt.timberkt.i
import com.github.ajalt.timberkt.w import com.github.ajalt.timberkt.w
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.android.material.textfield.TextInputEditText
import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher import com.zeapo.pwdstore.autofill.oreo.AutofillMatcher
import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel import com.zeapo.pwdstore.autofill.oreo.BrowserAutofillSupportLevel
import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel import com.zeapo.pwdstore.autofill.oreo.getInstalledBrowsersWithAutofillSupportLevel
@ -66,6 +67,7 @@ import com.zeapo.pwdstore.utils.PasswordRepository.Companion.isInitialized
import com.zeapo.pwdstore.utils.PasswordRepository.PasswordSortOrder.Companion.getSortOrder import com.zeapo.pwdstore.utils.PasswordRepository.PasswordSortOrder.Companion.getSortOrder
import com.zeapo.pwdstore.utils.commitChange import com.zeapo.pwdstore.utils.commitChange
import com.zeapo.pwdstore.utils.listFilesRecursively import com.zeapo.pwdstore.utils.listFilesRecursively
import com.zeapo.pwdstore.utils.requestInputFocusOnView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -628,7 +630,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
) )
.setPositiveButton(R.string.dialog_ok) { _, _ -> .setPositiveButton(R.string.dialog_ok) { _, _ ->
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
movePassword(source, destinationFile) moveFile(source, destinationFile)
} }
} }
.setNegativeButton(R.string.dialog_cancel, null) .setNegativeButton(R.string.dialog_cancel, null)
@ -636,7 +638,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
} }
} else { } else {
launch(Dispatchers.IO) { launch(Dispatchers.IO) {
movePassword(source, destinationFile) moveFile(source, destinationFile)
} }
} }
} }
@ -664,6 +666,69 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
}.launch(intent) }.launch(intent)
} }
private fun isInsideRepository(file: File): Boolean {
return file.canonicalPath.contains(getRepositoryDirectory(this).canonicalPath)
}
enum class CategoryRenameError(val resource: Int) {
None(0),
EmptyField(R.string.message_category_error_empty_field),
CategoryExists(R.string.message_category_error_category_exists),
DestinationOutsideRepo(R.string.message_category_error_destination_outside_repo),
}
/**
* Prompt the user with a new category name to assign,
* if the new category forms/leads a path (i.e. contains "/"), intermediate directories will be created
* and new category will be placed inside.
*
* @param oldCategory The category to change its name
* @param error Determines whether to show an error to the user in the alert dialog,
* this error may be due to the new category the user entered already exists or the field was empty or the
* destination path is outside the repository
*
* @see [CategoryRenameError]
* @see [isInsideRepository]
*/
private fun renameCategory(oldCategory: PasswordItem, error: CategoryRenameError = CategoryRenameError.None) {
val view = layoutInflater.inflate(R.layout.folder_dialog_fragment, null)
val newCategoryEditText = view.findViewById<TextInputEditText>(R.id.folder_name_text)
if (error != CategoryRenameError.None) {
newCategoryEditText.error = getString(error.resource)
}
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(R.string.title_rename_folder)
.setView(view)
.setMessage(getString(R.string.message_rename_folder, oldCategory.name))
.setPositiveButton(R.string.dialog_ok) { _, _ ->
val newCategory = File("${oldCategory.file.parent}/${newCategoryEditText.text}")
when {
newCategoryEditText.text.isNullOrBlank() -> renameCategory(oldCategory, CategoryRenameError.EmptyField)
newCategory.exists() -> renameCategory(oldCategory, CategoryRenameError.CategoryExists)
!isInsideRepository(newCategory) -> renameCategory(oldCategory, CategoryRenameError.DestinationOutsideRepo)
else -> lifecycleScope.launch(Dispatchers.IO) {
moveFile(oldCategory.file, newCategory)
withContext(Dispatchers.Main) {
commitChange(resources.getString(R.string.git_commit_move_text, oldCategory.name, newCategory.name))
}
}
}
}
.setNegativeButton(R.string.dialog_skip, null)
.create()
dialog.requestInputFocusOnView<TextInputEditText>(R.id.folder_name_text)
dialog.show()
}
fun renameCategory(categories: List<PasswordItem>) {
for (oldCategory in categories) {
renameCategory(oldCategory)
}
}
/** /**
* Resets navigation to the repository root and refreshes the password list accordingly. * Resets navigation to the repository root and refreshes the password list accordingly.
* *
@ -736,8 +801,9 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
super.onActivityResult(requestCode, resultCode, data) super.onActivityResult(requestCode, resultCode, data)
} }
private suspend fun movePassword(source: File, destinationFile: File) { private suspend fun moveFile(source: File, destinationFile: File) {
val sourceDestinationMap = if (source.isDirectory) { val sourceDestinationMap = if (source.isDirectory) {
destinationFile.mkdirs()
// Recursively list all files (not directories) below `source`, then // Recursively list all files (not directories) below `source`, then
// obtain the corresponding target file by resolving the relative path // obtain the corresponding target file by resolving the relative path
// starting at the destination folder. // starting at the destination folder.
@ -746,7 +812,7 @@ class PasswordStore : AppCompatActivity(R.layout.activity_pwdstore) {
mapOf(source to destinationFile) mapOf(source to destinationFile)
} }
if (!source.renameTo(destinationFile)) { if (!source.renameTo(destinationFile)) {
e { "Something went wrong while moving." } e { "Something went wrong while moving $source to $destinationFile." }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
MaterialAlertDialogBuilder(this@PasswordStore) MaterialAlertDialogBuilder(this@PasswordStore)
.setTitle(R.string.password_move_error_title) .setTitle(R.string.password_move_error_title)

View file

@ -20,7 +20,7 @@ class FolderCreationDialogFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext()) val alertDialogBuilder = MaterialAlertDialogBuilder(requireContext())
alertDialogBuilder.setTitle(R.string.title_create_folder) alertDialogBuilder.setTitle(R.string.title_create_folder)
alertDialogBuilder.setView(R.layout.folder_creation_dialog_fragment) alertDialogBuilder.setView(R.layout.folder_dialog_fragment)
alertDialogBuilder.setPositiveButton(getString(R.string.button_create)) { _, _ -> alertDialogBuilder.setPositiveButton(getString(R.string.button_create)) { _, _ ->
createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!) createDirectory(requireArguments().getString(CURRENT_DIR_EXTRA)!!)
} }

View file

@ -20,4 +20,9 @@
android:title="@string/delete" android:title="@string/delete"
app:showAsAction="ifRoom" /> app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_edit_password"
android:icon="@drawable/ic_edit_white_24dp"
android:title="@string/edit"
app:showAsAction="ifRoom" />
</menu> </menu>

View file

@ -222,6 +222,7 @@
<string name="dialog_positive">Go to Settings</string> <string name="dialog_positive">Go to Settings</string>
<string name="dialog_negative">Go back</string> <string name="dialog_negative">Go back</string>
<string name="dialog_cancel">Cancel</string> <string name="dialog_cancel">Cancel</string>
<string name="dialog_skip">Skip</string>
<string name="git_sync">Synchronize repository</string> <string name="git_sync">Synchronize repository</string>
<string name="git_pull">Pull from remote</string> <string name="git_pull">Pull from remote</string>
<string name="git_push">Push to remote</string> <string name="git_push">Push to remote</string>
@ -323,6 +324,11 @@
<string name="pref_show_hidden_title">Show hidden folders</string> <string name="pref_show_hidden_title">Show hidden folders</string>
<string name="pref_show_hidden_summary">Include hidden directories in the password list</string> <string name="pref_show_hidden_summary">Include hidden directories in the password list</string>
<string name="title_create_folder">Create folder</string> <string name="title_create_folder">Create folder</string>
<string name="title_rename_folder">Rename folder</string>
<string name="message_category_error_empty_field">Category name can\'t be empty</string>
<string name="message_category_error_category_exists">Category name already exists</string>
<string name="message_category_error_destination_outside_repo">Destination must be within the repository</string>
<string name="message_rename_folder">Enter destination for %1$s</string>
<string name="button_create">Create</string> <string name="button_create">Create</string>
<string name="pref_search_on_start">Open search on start</string> <string name="pref_search_on_start">Open search on start</string>
<string name="pref_search_on_start_hint">Open search bar when app is launched</string> <string name="pref_search_on_start_hint">Open search bar when app is launched</string>